1use std::collections::HashSet;
2use std::env;
3use std::path::{Path, PathBuf};
4use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
5use std::sync::Arc;
6use std::time::{Duration, Instant};
7
8use ignore::WalkBuilder;
9use rayon::prelude::*;
10
11use crate::commands::multi_path::{
12 canonical_key, dedupe_nested_paths, resolve_path_or_multi, SearchPathResolution,
13};
14use crate::context::AppContext;
15use crate::pattern_compile::{CompiledPattern, LiteralSearch};
16use crate::protocol::Response;
17use crate::search_index::{
18 build_path_filters, decompose_grep_pattern, has_any_project_file_from, read_searchable_text,
19 resolve_search_scope, sort_grep_matches_by_mtime_desc, sort_paths_by_mtime_desc,
20 try_read_with_budget, GrepMatch, GrepPathExclusion, GrepQueryPhaseTimings, GrepResult,
21 IndexStatus, PathFilters, RegexQuery, INTERACTIVE_ARTIFACT_READ_BUDGET,
22};
23
24pub(crate) const MAX_FALLBACK_WALK_FILES: usize = 50_000;
26pub(crate) const FALLBACK_WALK_BUDGET: Duration = Duration::from_secs(10);
28
29#[derive(Clone, Debug)]
30pub struct FallbackWalkOutcome {
31 pub files: Vec<PathBuf>,
32 pub walk_truncated: bool,
33 pub entries_visited: usize,
34}
35
36#[derive(Clone, Debug)]
37pub struct GrepParams {
38 pub include: Vec<String>,
39 pub exclude: Vec<String>,
40 pub max_results: usize,
41 pub path_exclusion: Option<GrepPathExclusion>,
42}
43
44#[derive(Clone, Copy, Debug, Default)]
45pub(crate) struct GrepExecutionPhaseTimings {
46 pub snapshot_acquire: Duration,
47 pub query_decomposition: Duration,
49 pub query: GrepQueryPhaseTimings,
50 pub indexed_scope_has_files: Option<bool>,
51}
52
53impl GrepExecutionPhaseTimings {
54 fn add(&mut self, other: Self) {
55 self.snapshot_acquire += other.snapshot_acquire;
56 self.query.trigram_lookup += other.query.trigram_lookup;
57 self.query.pread_verify += other.query.pread_verify;
58 self.query.post_filter += other.query.post_filter;
59 self.query.candidate_count += other.query.candidate_count;
60 self.query.bytes_verified += other.query.bytes_verified;
61 self.indexed_scope_has_files =
62 match (self.indexed_scope_has_files, other.indexed_scope_has_files) {
63 (Some(left), Some(right)) => Some(left || right),
64 _ => None,
65 };
66 }
67}
68
69#[derive(Clone, Debug)]
70pub struct GrepScope {
71 pub roots: Vec<ResolvedRoot>,
72 pub multi_root: bool,
73 pub per_root_max: usize,
74}
75
76#[derive(Clone, Debug)]
77pub struct ResolvedRoot {
78 pub search_root: PathBuf,
79 pub filter_root: PathBuf,
80 pub use_index: bool,
81 pub is_external: bool,
82}
83
84pub fn project_root(ctx: &AppContext) -> PathBuf {
85 let project_root = ctx
86 .config()
87 .project_root
88 .clone()
89 .unwrap_or_else(|| env::current_dir().unwrap_or_default());
90 std::fs::canonicalize(&project_root).unwrap_or(project_root)
91}
92
93pub fn resolve_grep_scope(
94 ctx: &AppContext,
95 paths: Option<&serde_json::Value>,
96 max_results: usize,
97 req_id: &str,
98) -> Result<GrepScope, Response> {
99 let project_root = project_root(ctx);
100 let search_roots = resolve_roots(ctx, paths, &project_root, req_id)?;
101
102 if let Some(missing_root) = search_roots.iter().find(|root| !root.exists()) {
103 return Err(Response::error(
104 req_id,
105 "path_not_found",
106 format!(
107 "grep: search path does not exist: {}",
108 missing_root.display()
109 ),
110 ));
111 }
112
113 let roots = search_roots
114 .into_iter()
115 .map(|search_root| {
116 let scope = resolve_search_scope(&project_root, Some(&search_root.to_string_lossy()));
117 let is_external = !scope.use_index;
118 let filter_root =
119 compute_filter_root(&project_root, &scope.root, scope.use_index, is_external);
120 ResolvedRoot {
121 search_root: scope.root,
122 filter_root,
123 use_index: scope.use_index,
124 is_external,
125 }
126 })
127 .collect::<Vec<_>>();
128
129 let multi_root = roots.len() > 1;
130 let per_root_max = if multi_root {
131 max_results.saturating_mul(2).max(max_results)
132 } else {
133 max_results
134 };
135
136 Ok(GrepScope {
137 roots,
138 multi_root,
139 per_root_max,
140 })
141}
142
143pub fn compute_filter_root(
144 project_root: &Path,
145 search_root: &Path,
146 use_index: bool,
147 is_external: bool,
148) -> PathBuf {
149 if is_external && !use_index {
150 search_root.to_path_buf()
151 } else {
152 project_root.to_path_buf()
153 }
154}
155
156pub fn scope_has_files(project_root: &Path, scope: &GrepScope) -> bool {
157 scope.roots.iter().any(|root| {
158 if root.search_root.is_file() {
162 return true;
163 }
164 let catch_all =
165 build_path_filters(&["**/*".to_string()], &[]).expect("valid catch-all glob");
166 has_any_project_file_from(&root.filter_root, &root.search_root, &catch_all)
167 || has_any_project_file_from(project_root, &root.search_root, &catch_all)
168 })
169}
170
171pub fn execute(
172 ctx: &AppContext,
173 pattern: &CompiledPattern,
174 scope: &GrepScope,
175 params: &GrepParams,
176) -> GrepResult {
177 execute_profiled(ctx, pattern, scope, params).0
178}
179
180pub(crate) fn execute_profiled(
181 ctx: &AppContext,
182 pattern: &CompiledPattern,
183 scope: &GrepScope,
184 params: &GrepParams,
185) -> (GrepResult, GrepExecutionPhaseTimings) {
186 let filters = build_path_filters(¶ms.include, ¶ms.exclude).unwrap_or_default();
187 execute_profiled_with_filters(ctx, pattern, scope, params, &filters)
188}
189
190pub(crate) fn execute_profiled_with_filters(
191 ctx: &AppContext,
192 pattern: &CompiledPattern,
193 scope: &GrepScope,
194 params: &GrepParams,
195 filters: &PathFilters,
196) -> (GrepResult, GrepExecutionPhaseTimings) {
197 let project_root = project_root(ctx);
198 let query_started = Instant::now();
199 let query = decompose_grep_pattern(pattern);
200 let query_decomposition = query_started.elapsed();
201 if scope.roots.len() == 1 {
202 let (result, mut phases) = execute_root_profiled(
203 ctx,
204 pattern,
205 &query,
206 &scope.roots[0],
207 params,
208 filters,
209 params.max_results,
210 &project_root,
211 );
212 phases.query_decomposition = query_decomposition;
213 return (result, phases);
214 }
215
216 let mut results = Vec::new();
217 let mut phases: Option<GrepExecutionPhaseTimings> = None;
218 for root in &scope.roots {
219 let (result, root_phases) = execute_root_profiled(
220 ctx,
221 pattern,
222 &query,
223 root,
224 params,
225 filters,
226 scope.per_root_max,
227 &project_root,
228 );
229 results.push(result);
230 if let Some(phases) = phases.as_mut() {
231 phases.add(root_phases);
232 } else {
233 phases = Some(root_phases);
234 }
235 }
236 let mut phases = phases.unwrap_or_default();
237 phases.query_decomposition = query_decomposition;
238 (
239 merge_grep_results(results, &project_root, params.max_results),
240 phases,
241 )
242}
243
244fn resolve_roots(
245 ctx: &AppContext,
246 paths: Option<&serde_json::Value>,
247 project_root: &Path,
248 req_id: &str,
249) -> Result<Vec<PathBuf>, Response> {
250 let Some(paths) = paths else {
251 return Ok(vec![resolve_search_scope(project_root, None).root]);
252 };
253 if paths.is_null() {
254 return Ok(vec![resolve_search_scope(project_root, None).root]);
255 }
256 if let Some(path) = paths.as_str() {
257 return match resolve_path_or_multi(
258 path,
259 project_root,
260 |candidate| ctx.validate_path(req_id, candidate),
261 req_id,
262 )? {
263 SearchPathResolution::Single(root) => Ok(vec![root]),
264 SearchPathResolution::Multi(roots) => Ok(roots),
265 };
266 }
267 if let Some(items) = paths.as_array() {
268 let mut roots = Vec::with_capacity(items.len());
269 for item in items {
270 let Some(path) = item.as_str() else {
271 return Err(Response::error(
272 req_id,
273 "invalid_request",
274 "grep: path array entries must be strings",
275 ));
276 };
277 let validated = ctx.validate_path(req_id, Path::new(path))?;
278 let raw = validated.to_string_lossy();
279 roots.push(resolve_search_scope(project_root, Some(raw.as_ref())).root);
280 }
281 let roots = dedupe_nested_paths(roots);
282 if roots.is_empty() {
283 Ok(vec![resolve_search_scope(project_root, None).root])
284 } else {
285 Ok(roots)
286 }
287 } else {
288 Err(Response::error(
289 req_id,
290 "invalid_request",
291 "grep: path must be a string, array of strings, or null",
292 ))
293 }
294}
295
296fn execute_root_profiled(
297 ctx: &AppContext,
298 pattern: &CompiledPattern,
299 query: &RegexQuery,
300 root: &ResolvedRoot,
301 params: &GrepParams,
302 filters: &PathFilters,
303 max_results: usize,
304 project_root: &Path,
305) -> (GrepResult, GrepExecutionPhaseTimings) {
306 if root.search_root.is_file() {
311 if root.use_index {
312 crate::commands::configure::trigger_search_index_reload_if_evicted(ctx);
313 }
314 let index_status = if root.use_index {
315 current_index_status(ctx)
316 } else {
317 IndexStatus::Fallback
318 };
319 let result = if params
320 .path_exclusion
321 .is_some_and(|exclude| exclude(&root.search_root, project_root))
322 {
323 empty_grep_result(index_status, false)
324 } else {
325 grep_explicit_file(&root.search_root, pattern, max_results, index_status)
326 };
327 return (result, GrepExecutionPhaseTimings::default());
328 }
329
330 let snapshot_started = Instant::now();
331 let mut snapshot_timed_out = false;
332 let indexed_snapshot =
333 match try_read_with_budget(ctx.search_index(), INTERACTIVE_ARTIFACT_READ_BUDGET) {
334 Some(search_index) => match search_index.as_ref() {
335 Some(index) if index.ready && root.use_index => Some(index.snapshot()),
336 _ => None,
337 },
338 None => {
339 snapshot_timed_out = true;
340 None
341 }
342 };
343 let snapshot_acquire = snapshot_started.elapsed();
344 if let Some(snapshot) = indexed_snapshot {
345 let scope_started = Instant::now();
346 let indexed_scope_has_files = snapshot.has_file_in_scope(&root.search_root);
347 let scope_elapsed = scope_started.elapsed();
348 let (result, mut query_timings) = snapshot.search_grep_profiled_with_filters_and_query(
349 pattern,
350 query,
351 filters,
352 &root.search_root,
353 max_results,
354 params.path_exclusion,
355 );
356 query_timings.post_filter += scope_elapsed;
357 return (
358 result,
359 GrepExecutionPhaseTimings {
360 snapshot_acquire,
361 query_decomposition: Duration::ZERO,
362 query: query_timings,
363 indexed_scope_has_files: Some(indexed_scope_has_files),
364 },
365 );
366 }
367
368 if root.use_index {
369 crate::commands::configure::trigger_search_index_reload_if_evicted(ctx);
370 }
371 let index_status = if root.use_index {
372 if snapshot_timed_out {
373 IndexStatus::Fallback
374 } else {
375 current_index_status(ctx)
376 }
377 } else {
378 IndexStatus::Fallback
379 };
380 (
381 fallback_grep(
382 project_root,
383 &root.search_root,
384 &root.filter_root,
385 pattern,
386 filters,
387 max_results,
388 index_status,
389 params.path_exclusion,
390 ),
391 GrepExecutionPhaseTimings {
392 snapshot_acquire,
393 ..GrepExecutionPhaseTimings::default()
394 },
395 )
396}
397
398fn empty_grep_result(index_status: IndexStatus, fully_degraded: bool) -> GrepResult {
399 GrepResult {
400 matches: Vec::new(),
401 total_matches: 0,
402 files_searched: 0,
403 files_with_matches: 0,
404 index_status,
405 truncated: false,
406 fully_degraded,
407 engine_capped: false,
408 walk_truncated: false,
409 }
410}
411
412fn grep_explicit_file(
419 file: &Path,
420 pattern: &CompiledPattern,
421 max_results: usize,
422 index_status: IndexStatus,
423) -> GrepResult {
424 let total_matches = AtomicUsize::new(0);
425 let files_searched = AtomicUsize::new(0);
426 let files_with_matches = AtomicUsize::new(0);
427 let truncated = AtomicBool::new(false);
428 let engine_capped = AtomicBool::new(false);
429 let stop_after = max_results.saturating_mul(2);
430 let job_cancellation = crate::executor::current_job_cancellation();
431
432 let matches = fallback_search_file(
433 &file.to_path_buf(),
434 pattern,
435 max_results,
436 stop_after,
437 &total_matches,
438 &files_searched,
439 &files_with_matches,
440 &truncated,
441 &engine_capped,
442 job_cancellation.as_ref(),
443 None,
444 );
445
446 GrepResult {
447 total_matches: total_matches.load(Ordering::Relaxed),
448 matches,
449 files_searched: files_searched.load(Ordering::Relaxed),
450 files_with_matches: files_with_matches.load(Ordering::Relaxed),
451 index_status,
452 truncated: truncated.load(Ordering::Relaxed),
453 fully_degraded: false,
454 engine_capped: engine_capped.load(Ordering::Relaxed),
455 walk_truncated: false,
456 }
457}
458
459pub fn merge_grep_results(
460 results: Vec<GrepResult>,
461 project_root: &Path,
462 max_results: usize,
463) -> GrepResult {
464 let mut matches = Vec::new();
465 let mut total_matches = 0usize;
466 let mut files_searched = 0usize;
467 let mut files_with_matches = 0usize;
468 let mut index_status = IndexStatus::Ready;
469 let mut any_child_truncated = false;
470 let mut fully_degraded = false;
471 let mut engine_capped = false;
472 let mut walk_truncated = false;
473 let mut seen_match_keys = HashSet::new();
474
475 for result in results {
476 total_matches += result.total_matches;
477 files_searched += result.files_searched;
478 files_with_matches += result.files_with_matches;
479 index_status = weakest_index_status(index_status, result.index_status);
480 any_child_truncated |= result.truncated;
481 fully_degraded |= result.fully_degraded;
482 engine_capped |= result.engine_capped;
483 walk_truncated |= result.walk_truncated;
484
485 for grep_match in result.matches {
486 let file_key = canonical_key(&grep_match.file);
487 let match_key = (file_key, grep_match.line, grep_match.column);
488 if seen_match_keys.insert(match_key) {
489 matches.push(grep_match);
490 }
491 }
492 }
493
494 sort_grep_matches_by_mtime_desc(&mut matches, project_root);
495 if matches.len() > max_results {
496 matches.truncate(max_results);
497 }
498
499 GrepResult {
500 matches,
501 total_matches,
502 files_searched,
503 files_with_matches,
504 index_status,
505 truncated: any_child_truncated || total_matches > max_results,
506 fully_degraded,
507 engine_capped,
508 walk_truncated,
509 }
510}
511
512fn fallback_project_walk_builder(search_root: &Path) -> WalkBuilder {
513 let mut builder = WalkBuilder::new(search_root);
514 builder
515 .hidden(false)
516 .git_ignore(true)
517 .git_global(true)
518 .git_exclude(true)
519 .add_custom_ignore_filename(".aftignore")
520 .filter_entry(|entry| {
521 let name = entry.file_name().to_string_lossy();
522 if entry.file_type().map_or(false, |ft| ft.is_dir()) {
523 return !matches!(
524 name.as_ref(),
525 "node_modules"
526 | "target"
527 | "venv"
528 | ".venv"
529 | ".git"
530 | "__pycache__"
531 | ".tox"
532 | "dist"
533 | "build"
534 );
535 }
536 true
537 });
538 builder
539}
540
541pub(crate) fn bounded_fallback_walk_files(
543 filter_root: &Path,
544 search_root: &Path,
545 filters: &PathFilters,
546) -> FallbackWalkOutcome {
547 bounded_fallback_walk_files_with_limits(
548 filter_root,
549 search_root,
550 filters,
551 MAX_FALLBACK_WALK_FILES,
552 FALLBACK_WALK_BUDGET,
553 )
554}
555
556fn bounded_fallback_walk_files_with_limits(
557 filter_root: &Path,
558 search_root: &Path,
559 filters: &PathFilters,
560 max_files: usize,
561 budget: Duration,
562) -> FallbackWalkOutcome {
563 let started = Instant::now();
564 let mut files = Vec::new();
565 let mut walk_truncated = false;
566 let mut entries_visited = 0usize;
567 let builder = fallback_project_walk_builder(search_root);
568
569 for entry in builder.build().filter_map(|entry| entry.ok()) {
570 entries_visited += 1;
571 if started.elapsed() >= budget {
572 walk_truncated = true;
573 break;
574 }
575 if !entry
576 .file_type()
577 .map_or(false, |file_type| file_type.is_file())
578 {
579 continue;
580 }
581 let path = entry.into_path();
582 if filters.matches(filter_root, &path) {
583 files.push(path);
584 if files.len() > max_files {
585 walk_truncated = true;
586 files.truncate(max_files);
587 break;
588 }
589 }
590 }
591
592 sort_paths_by_mtime_desc(&mut files, filter_root);
593 FallbackWalkOutcome {
594 files,
595 walk_truncated,
596 entries_visited,
597 }
598}
599
600pub(crate) fn for_each_bounded_fallback_walk_file<F>(
601 filter_root: &Path,
602 search_root: &Path,
603 filters: &PathFilters,
604 project_root: &Path,
605 path_exclusion: Option<GrepPathExclusion>,
606 mut on_file: F,
607) -> bool
608where
609 F: FnMut(&PathBuf),
610{
611 for_each_bounded_fallback_walk_file_with_limits(
612 filter_root,
613 search_root,
614 filters,
615 project_root,
616 path_exclusion,
617 MAX_FALLBACK_WALK_FILES,
618 FALLBACK_WALK_BUDGET,
619 &mut on_file,
620 )
621}
622
623fn for_each_bounded_fallback_walk_file_with_limits<F>(
624 filter_root: &Path,
625 search_root: &Path,
626 filters: &PathFilters,
627 project_root: &Path,
628 path_exclusion: Option<GrepPathExclusion>,
629 max_files: usize,
630 budget: Duration,
631 on_file: &mut F,
632) -> bool
633where
634 F: FnMut(&PathBuf),
635{
636 let started = Instant::now();
637 let mut files_seen = 0usize;
638 let builder = fallback_project_walk_builder(search_root);
639
640 for entry in builder.build().filter_map(|entry| entry.ok()) {
641 if crate::executor::current_job_cancelled() {
642 return true;
643 }
644 if started.elapsed() >= budget {
645 return true;
646 }
647 if !entry
648 .file_type()
649 .map_or(false, |file_type| file_type.is_file())
650 {
651 continue;
652 }
653 let path = entry.into_path();
654 if path_exclusion.is_some_and(|exclude| exclude(&path, project_root)) {
655 continue;
656 }
657 if filters.matches(filter_root, &path) {
658 files_seen += 1;
659 if files_seen > max_files {
660 return true;
661 }
662 on_file(&path);
663 }
664 }
665 false
666}
667
668pub fn weakest_index_status(left: IndexStatus, right: IndexStatus) -> IndexStatus {
669 match (left, right) {
670 (IndexStatus::Disabled, _) | (_, IndexStatus::Disabled) => IndexStatus::Disabled,
671 (IndexStatus::Fallback, _) | (_, IndexStatus::Fallback) => IndexStatus::Fallback,
672 (IndexStatus::Building, _) | (_, IndexStatus::Building) => IndexStatus::Building,
673 (IndexStatus::Ready, IndexStatus::Ready) => IndexStatus::Ready,
674 }
675}
676
677#[doc(hidden)]
679pub fn fallback_grep_bench(
680 project_root: &Path,
681 search_root: &Path,
682 filter_root: &Path,
683 pattern: &CompiledPattern,
684 include: &[String],
685 exclude: &[String],
686 max_results: usize,
687) -> GrepResult {
688 let filters = build_path_filters(include, exclude).unwrap_or_default();
689 fallback_grep(
690 project_root,
691 search_root,
692 filter_root,
693 pattern,
694 &filters,
695 max_results,
696 IndexStatus::Fallback,
697 None,
698 )
699}
700
701fn fallback_grep(
702 project_root: &Path,
703 search_root: &Path,
704 filter_root: &Path,
705 pattern: &CompiledPattern,
706 filters: &PathFilters,
707 max_results: usize,
708 index_status: IndexStatus,
709 path_exclusion: Option<GrepPathExclusion>,
710) -> GrepResult {
711 let total_matches = AtomicUsize::new(0);
712 let files_searched = AtomicUsize::new(0);
713 let files_with_matches = AtomicUsize::new(0);
714 let truncated = AtomicBool::new(false);
715 let engine_capped = AtomicBool::new(false);
716 let stop_after = max_results.saturating_mul(2);
717 let stop_scan = Arc::new(AtomicBool::new(false));
718 let scan_deadline = Instant::now() + FALLBACK_WALK_BUDGET;
719 let job_cancellation = crate::executor::current_job_cancellation();
720
721 let mut matches = Vec::new();
722 let mut batch: Vec<PathBuf> = Vec::with_capacity(256);
723
724 let flush_batch = |batch: &mut Vec<PathBuf>, matches: &mut Vec<GrepMatch>| {
725 if batch.is_empty() {
726 return;
727 }
728 let chunk = std::mem::take(batch);
729 let partial: Vec<GrepMatch> = chunk
730 .par_iter()
731 .filter_map(|file| {
732 if stop_scan.load(Ordering::Relaxed)
733 || Instant::now() >= scan_deadline
734 || job_cancellation
735 .as_ref()
736 .is_some_and(|token| token.cancel_requested_before_commit())
737 {
738 return None;
739 }
740 let file_matches = fallback_search_file(
741 file,
742 pattern,
743 max_results,
744 stop_after,
745 &total_matches,
746 &files_searched,
747 &files_with_matches,
748 &truncated,
749 &engine_capped,
750 job_cancellation.as_ref(),
751 Some(scan_deadline),
752 );
753 if truncated.load(Ordering::Relaxed)
754 && total_matches.load(Ordering::Relaxed) >= stop_after
755 {
756 stop_scan.store(true, Ordering::Relaxed);
757 }
758 (!file_matches.is_empty()).then_some(file_matches)
759 })
760 .flatten()
761 .collect();
762 matches.extend(partial);
763 };
764
765 let mut walk_truncated = for_each_bounded_fallback_walk_file(
766 filter_root,
767 search_root,
768 filters,
769 project_root,
770 path_exclusion,
771 |path| {
772 if stop_scan.load(Ordering::Relaxed) {
773 return;
774 }
775 batch.push(path.clone());
776 if batch.len() >= 256 {
777 flush_batch(&mut batch, &mut matches);
778 }
779 },
780 );
781 flush_batch(&mut batch, &mut matches);
782 if Instant::now() >= scan_deadline {
783 walk_truncated = true;
784 engine_capped.store(true, Ordering::Relaxed);
785 }
786
787 sort_grep_matches_by_mtime_desc(&mut matches, project_root);
788
789 GrepResult {
790 total_matches: total_matches.load(Ordering::Relaxed),
791 matches,
792 files_searched: files_searched.load(Ordering::Relaxed),
793 files_with_matches: files_with_matches.load(Ordering::Relaxed),
794 index_status,
795 truncated: truncated.load(Ordering::Relaxed),
796 fully_degraded: true,
797 engine_capped: engine_capped.load(Ordering::Relaxed),
798 walk_truncated,
799 }
800}
801
802fn fallback_search_file(
803 file: &PathBuf,
804 pattern: &CompiledPattern,
805 max_results: usize,
806 stop_after: usize,
807 total_matches: &AtomicUsize,
808 files_searched: &AtomicUsize,
809 files_with_matches: &AtomicUsize,
810 truncated: &AtomicBool,
811 engine_capped: &AtomicBool,
812 job_cancellation: Option<&crate::executor::JobCancellation>,
813 deadline: Option<Instant>,
814) -> Vec<GrepMatch> {
815 if deadline.is_some_and(|deadline| Instant::now() >= deadline)
816 || should_stop_fallback_search(truncated, total_matches, stop_after, job_cancellation)
817 {
818 engine_capped.store(true, Ordering::Relaxed);
819 return Vec::new();
820 }
821
822 let Some(content) = read_searchable_text(file) else {
823 return Vec::new();
824 };
825 files_searched.fetch_add(1, Ordering::Relaxed);
826
827 let line_starts = line_starts(&content);
828 let mut seen_lines = HashSet::new();
829 let mut matched_this_file = false;
830 let mut matches = Vec::new();
831
832 match pattern {
833 CompiledPattern::Literal(literal) => search_literal_in_text(
834 file,
835 &content,
836 &line_starts,
837 literal,
838 max_results,
839 stop_after,
840 total_matches,
841 &mut seen_lines,
842 truncated,
843 engine_capped,
844 &mut matched_this_file,
845 &mut matches,
846 job_cancellation,
847 deadline,
848 ),
849 CompiledPattern::Regex { compiled, .. } => {
850 for matched in compiled.find_iter(content.as_bytes()) {
851 if deadline.is_some_and(|deadline| Instant::now() >= deadline)
852 || should_stop_fallback_search(
853 truncated,
854 total_matches,
855 stop_after,
856 job_cancellation,
857 )
858 {
859 engine_capped.store(true, Ordering::Relaxed);
860 break;
861 }
862
863 let (line, column, line_text) =
864 line_details(&content, &line_starts, matched.start());
865 if !seen_lines.insert(line) {
866 continue;
867 }
868
869 matched_this_file = true;
870 let match_number = total_matches.fetch_add(1, Ordering::Relaxed) + 1;
871 if match_number > max_results {
872 truncated.store(true, Ordering::Relaxed);
873 break;
874 }
875
876 matches.push(GrepMatch {
877 file: file.clone(),
878 line,
879 column,
880 line_text,
881 match_text: String::from_utf8_lossy(matched.as_bytes()).into_owned(),
882 });
883 }
884 }
885 }
886
887 if matched_this_file {
888 files_with_matches.fetch_add(1, Ordering::Relaxed);
889 }
890
891 matches
892}
893
894fn search_literal_in_text(
895 file: &Path,
896 content: &str,
897 line_starts: &[usize],
898 literal: &LiteralSearch,
899 max_results: usize,
900 stop_after: usize,
901 total_matches: &AtomicUsize,
902 seen_lines: &mut HashSet<u32>,
903 truncated: &AtomicBool,
904 engine_capped: &AtomicBool,
905 matched_this_file: &mut bool,
906 matches: &mut Vec<GrepMatch>,
907 job_cancellation: Option<&crate::executor::JobCancellation>,
908 deadline: Option<Instant>,
909) {
910 let content_bytes = content.as_bytes();
911 let search_content;
912 let haystack = if literal.case_insensitive_ascii {
913 search_content = content_bytes.to_ascii_lowercase();
914 search_content.as_slice()
915 } else {
916 content_bytes
917 };
918 let finder = memchr::memmem::Finder::new(&literal.needle);
919 let mut start = 0usize;
920
921 while let Some(position) = finder.find(&haystack[start..]) {
922 if deadline.is_some_and(|deadline| Instant::now() >= deadline)
923 || should_stop_fallback_search(truncated, total_matches, stop_after, job_cancellation)
924 {
925 engine_capped.store(true, Ordering::Relaxed);
926 break;
927 }
928
929 let offset = start + position;
930 start = offset + 1;
931 let (line, column, line_text) = line_details(content, line_starts, offset);
932 if !seen_lines.insert(line) {
933 continue;
934 }
935
936 *matched_this_file = true;
937 let match_number = total_matches.fetch_add(1, Ordering::Relaxed) + 1;
938 if match_number > max_results {
939 truncated.store(true, Ordering::Relaxed);
940 break;
941 }
942
943 let end = offset + literal.needle.len();
944 matches.push(GrepMatch {
945 file: file.to_path_buf(),
946 line,
947 column,
948 line_text,
949 match_text: String::from_utf8_lossy(&content_bytes[offset..end]).into_owned(),
950 });
951 }
952}
953
954fn should_stop_fallback_search(
955 truncated: &AtomicBool,
956 total_matches: &AtomicUsize,
957 stop_after: usize,
958 job_cancellation: Option<&crate::executor::JobCancellation>,
959) -> bool {
960 job_cancellation.is_some_and(|token| token.cancel_requested_before_commit())
961 || (truncated.load(Ordering::Relaxed)
962 && total_matches.load(Ordering::Relaxed) >= stop_after)
963}
964
965pub(crate) fn ripgrep_glob(
966 search_root: &Path,
967 pattern: &str,
968 max_results: usize,
969) -> Option<FallbackWalkOutcome> {
970 let filters = build_path_filters(&[pattern.to_string()], &[]).ok()?;
971 let mut outcome = bounded_fallback_walk_files(search_root, search_root, &filters);
972 outcome.files.truncate(max_results);
973 Some(outcome)
974}
975
976fn current_index_status(ctx: &AppContext) -> IndexStatus {
977 let Some(search_index) =
978 try_read_with_budget(ctx.search_index(), INTERACTIVE_ARTIFACT_READ_BUDGET)
979 else {
980 return IndexStatus::Fallback;
981 };
982 if search_index.as_ref().is_some_and(|index| index.ready) {
983 return IndexStatus::Ready;
984 }
985
986 let build_in_progress =
987 try_read_with_budget(ctx.search_index_rx(), INTERACTIVE_ARTIFACT_READ_BUDGET)
988 .is_some_and(|search_index_rx| search_index_rx.is_some());
989 if build_in_progress || search_index.is_some() {
990 IndexStatus::Building
991 } else {
992 IndexStatus::Fallback
993 }
994}
995
996pub fn line_starts(content: &str) -> Vec<usize> {
997 let mut starts = vec![0usize];
998 for (index, byte) in content.bytes().enumerate() {
999 if byte == b'\n' {
1000 starts.push(index + 1);
1001 }
1002 }
1003 starts
1004}
1005
1006pub fn floor_char_boundary_str(content: &str, mut index: usize) -> usize {
1008 index = index.min(content.len());
1009 while index > 0 && !content.is_char_boundary(index) {
1010 index -= 1;
1011 }
1012 index
1013}
1014
1015pub fn truncate_at_char_boundary(content: &str, max_bytes: usize) -> &str {
1017 let end = floor_char_boundary_str(content, max_bytes);
1018 &content[..end]
1019}
1020
1021pub fn line_details(content: &str, line_starts: &[usize], offset: usize) -> (u32, u32, String) {
1022 let offset = floor_char_boundary_str(content, offset);
1023 let line_index = match line_starts.binary_search(&offset) {
1024 Ok(index) => index,
1025 Err(index) => index.saturating_sub(1),
1026 };
1027 let line_start = line_starts.get(line_index).copied().unwrap_or(0);
1028 let line_end = content[line_start..]
1029 .find('\n')
1030 .map(|length| line_start + length)
1031 .unwrap_or(content.len());
1032 let line_text = content[line_start..line_end]
1033 .trim_end_matches('\r')
1034 .to_string();
1035 let column = content[line_start..offset].chars().count() as u32 + 1;
1036 (line_index as u32 + 1, column, line_text)
1037}
1038
1039#[cfg(test)]
1040mod tests {
1041 use super::*;
1042
1043 fn grep_match(file: &Path, line: u32, column: u32) -> GrepMatch {
1044 GrepMatch {
1045 file: file.to_path_buf(),
1046 line,
1047 column,
1048 line_text: "needle".to_string(),
1049 match_text: "needle".to_string(),
1050 }
1051 }
1052
1053 fn result(matches: Vec<GrepMatch>, truncated: bool, status: IndexStatus) -> GrepResult {
1054 GrepResult {
1055 total_matches: matches.len(),
1056 files_searched: matches.len(),
1057 files_with_matches: matches.len(),
1058 matches,
1059 index_status: status,
1060 truncated,
1061 fully_degraded: false,
1062 engine_capped: false,
1063 walk_truncated: false,
1064 }
1065 }
1066
1067 #[test]
1068 fn optional_path_exclusion_controls_visible_totals_without_affecting_default_grep() {
1069 fn excludes_tests(path: &Path, root: &Path) -> bool {
1070 path.strip_prefix(root)
1071 .is_ok_and(|relative| relative.starts_with("tests"))
1072 }
1073
1074 let project = tempfile::tempdir().expect("project");
1075 let test_file = project.path().join("tests/case.rs");
1076 let source_file = project.path().join("src/lib.rs");
1077 std::fs::create_dir_all(test_file.parent().expect("test parent")).expect("test dir");
1078 std::fs::create_dir_all(source_file.parent().expect("source parent")).expect("source dir");
1079 std::fs::write(&test_file, "const NEEDLE: &str = \"needle\";\n").expect("test file");
1080 std::fs::write(&source_file, "pub fn needle() {}\n").expect("source file");
1081 let pattern = match crate::pattern_compile::compile(
1082 "needle",
1083 crate::pattern_compile::CompileOpts {
1084 literal: true,
1085 ..crate::pattern_compile::CompileOpts::default()
1086 },
1087 ) {
1088 crate::pattern_compile::CompileResult::Ok(pattern) => pattern,
1089 other => panic!("compile literal: {other:?}"),
1090 };
1091
1092 let filters = PathFilters::default();
1093 let unfiltered = fallback_grep(
1094 project.path(),
1095 project.path(),
1096 project.path(),
1097 &pattern,
1098 &filters,
1099 10,
1100 IndexStatus::Fallback,
1101 None,
1102 );
1103 assert_eq!(unfiltered.total_matches, 2);
1104 assert_eq!(unfiltered.matches.len(), 2);
1105
1106 let visible = fallback_grep(
1107 project.path(),
1108 project.path(),
1109 project.path(),
1110 &pattern,
1111 &filters,
1112 10,
1113 IndexStatus::Fallback,
1114 Some(excludes_tests),
1115 );
1116 assert_eq!(visible.total_matches, 1);
1117 assert_eq!(visible.matches.len(), 1);
1118 assert_eq!(visible.files_searched, 1);
1119 assert_eq!(visible.files_with_matches, 1);
1120 assert_eq!(visible.matches[0].file, source_file);
1121 assert!(!visible.truncated);
1122 assert!(!visible.engine_capped);
1123 }
1124
1125 #[test]
1126 fn single_root_uses_requested_max() {
1127 let scope = GrepScope {
1128 roots: vec![ResolvedRoot {
1129 search_root: PathBuf::from("/project"),
1130 filter_root: PathBuf::from("/project"),
1131 use_index: true,
1132 is_external: false,
1133 }],
1134 multi_root: false,
1135 per_root_max: 10,
1136 };
1137 assert!(!scope.multi_root);
1138 assert_eq!(scope.per_root_max, 10);
1139 }
1140
1141 #[test]
1142 fn multi_root_uses_double_per_root_max() {
1143 let project = tempfile::tempdir().expect("project");
1144 let ctx = AppContext::new(
1145 Box::new(crate::parser::TreeSitterProvider::new()),
1146 crate::config::Config {
1147 project_root: Some(project.path().to_path_buf()),
1148 ..crate::config::Config::default()
1149 },
1150 );
1151 let left = project.path().join("left");
1152 let right = project.path().join("right");
1153 std::fs::create_dir_all(&left).expect("left");
1154 std::fs::create_dir_all(&right).expect("right");
1155 let paths = serde_json::json!([left.display().to_string(), right.display().to_string()]);
1156
1157 let scope = resolve_grep_scope(&ctx, Some(&paths), 10, "test").expect("scope");
1158
1159 assert!(scope.multi_root);
1160 assert_eq!(scope.per_root_max, 20);
1161 }
1162
1163 #[test]
1164 fn bounded_fallback_walk_truncates_at_file_cap() {
1165 let dir = tempfile::tempdir().expect("tempdir");
1166 let root = dir.path();
1167 for i in 0..25 {
1168 let path = root.join(format!("file_{i:03}.txt"));
1169 std::fs::write(path, "needle\n").expect("write");
1170 }
1171 let filters = build_path_filters(&["**/*.txt".to_string()], &[]).expect("filters");
1172 let outcome = bounded_fallback_walk_files_with_limits(
1173 root,
1174 root,
1175 &filters,
1176 20,
1177 Duration::from_secs(60),
1178 );
1179 assert!(outcome.walk_truncated);
1180 assert_eq!(outcome.files.len(), 20);
1181 }
1182
1183 #[test]
1184 fn bounded_fallback_walk_small_tree_not_truncated() {
1185 let dir = tempfile::tempdir().expect("tempdir");
1186 let root = dir.path();
1187 std::fs::write(root.join("a.txt"), "x\n").expect("write");
1188 std::fs::write(root.join("b.txt"), "x\n").expect("write");
1189 let filters = build_path_filters(&["**/*.txt".to_string()], &[]).expect("filters");
1190 let outcome = bounded_fallback_walk_files(root, root, &filters);
1191 assert!(!outcome.walk_truncated);
1192 assert_eq!(outcome.files.len(), 2);
1193 }
1194
1195 #[test]
1196 fn filter_root_is_project_for_in_project_and_search_root_for_external_unindexed() {
1197 let project = PathBuf::from("/project");
1198 let in_project = compute_filter_root(&project, Path::new("/project/src"), true, false);
1199 let external = compute_filter_root(&project, Path::new("/tmp/external"), false, true);
1200 assert_eq!(in_project, project);
1201 assert_eq!(external, PathBuf::from("/tmp/external"));
1202 }
1203
1204 #[test]
1205 fn weakest_status_orders_disabled_fallback_building_ready() {
1206 assert_eq!(
1207 weakest_index_status(IndexStatus::Ready, IndexStatus::Building),
1208 IndexStatus::Building
1209 );
1210 assert_eq!(
1211 weakest_index_status(IndexStatus::Building, IndexStatus::Fallback),
1212 IndexStatus::Fallback
1213 );
1214 assert_eq!(
1215 weakest_index_status(IndexStatus::Fallback, IndexStatus::Disabled),
1216 IndexStatus::Disabled
1217 );
1218 }
1219
1220 #[test]
1221 fn merge_dedupes_by_canonical_file_line_column() {
1222 let temp = tempfile::tempdir().expect("temp");
1223 let file = temp.path().join("file.rs");
1224 std::fs::write(&file, "needle").expect("write");
1225 let symlink = temp.path().join("link.rs");
1226 #[cfg(unix)]
1227 std::os::unix::fs::symlink(&file, &symlink).expect("symlink");
1228 #[cfg(windows)]
1229 std::os::windows::fs::symlink_file(&file, &symlink).expect("symlink");
1230
1231 let merged = merge_grep_results(
1232 vec![
1233 result(vec![grep_match(&file, 1, 1)], false, IndexStatus::Ready),
1234 result(vec![grep_match(&symlink, 1, 1)], false, IndexStatus::Ready),
1235 ],
1236 temp.path(),
1237 10,
1238 );
1239
1240 assert_eq!(merged.matches.len(), 1);
1241 }
1242
1243 #[test]
1244 fn merge_truncated_when_child_truncated_or_pre_merge_exceeds_max() {
1245 let root = Path::new("/project");
1246 let child = merge_grep_results(
1247 vec![result(
1248 vec![grep_match(Path::new("/project/a.rs"), 1, 1)],
1249 true,
1250 IndexStatus::Ready,
1251 )],
1252 root,
1253 10,
1254 );
1255 assert!(child.truncated);
1256
1257 let many = merge_grep_results(
1258 vec![
1259 result(
1260 vec![grep_match(Path::new("/project/a.rs"), 1, 1)],
1261 false,
1262 IndexStatus::Ready,
1263 ),
1264 result(
1265 vec![grep_match(Path::new("/project/b.rs"), 1, 1)],
1266 false,
1267 IndexStatus::Ready,
1268 ),
1269 ],
1270 root,
1271 1,
1272 );
1273 assert!(many.truncated);
1274 }
1275
1276 #[test]
1277 fn line_details_floors_offset_inside_multibyte_char() {
1278 let content = "before—after";
1279 let starts = line_starts(content);
1280 let dash_byte = content.find('—').expect("em dash");
1281 let mid_byte = dash_byte + 1;
1282 assert!(!content.is_char_boundary(mid_byte));
1283 let (line, column, line_text) = line_details(content, &starts, mid_byte);
1284 assert_eq!(line, 1);
1285 assert_eq!(column, content[..dash_byte].chars().count() as u32 + 1);
1286 assert!(line_text.contains('—'));
1287 }
1288
1289 #[test]
1290 fn line_details_clamps_offset_past_end() {
1291 let content = "short";
1292 let starts = line_starts(content);
1293 let (line, column, _) = line_details(content, &starts, content.len() + 100);
1294 assert_eq!(line, 1);
1295 assert_eq!(column, 6);
1296 }
1297
1298 #[test]
1299 fn truncate_at_char_boundary_floors_mid_multibyte_at_byte_cap() {
1300 let mut prefix = "a".repeat(38);
1301 prefix.push('—');
1302 prefix.push_str("tail");
1303 assert_eq!(prefix.len(), 45);
1304 assert!(!prefix.is_char_boundary(40));
1305 let truncated = truncate_at_char_boundary(&prefix, 40);
1306 assert!(truncated.is_char_boundary(truncated.len()));
1307 assert!(truncated.ends_with('a'));
1308 assert!(!truncated.contains('—'));
1309 }
1310
1311 #[test]
1312 fn regex_byte_match_start_mid_char_does_not_panic_in_line_details() {
1313 use crate::pattern_compile::{CompileOpts, CompileResult};
1314
1315 let content = "xy—zz";
1316 let starts = line_starts(content);
1317 let compiled = match crate::pattern_compile::compile(
1318 ".",
1319 CompileOpts {
1320 multi_line: false,
1321 ..CompileOpts::default()
1322 },
1323 ) {
1324 CompileResult::Ok(compiled) => compiled,
1325 other => panic!("expected compiled pattern, got {other:?}"),
1326 };
1327 let crate::pattern_compile::CompiledPattern::Regex { compiled, .. } = compiled else {
1328 panic!("expected regex pattern");
1329 };
1330 for matched in compiled.find_iter(content.as_bytes()) {
1331 let _ = line_details(content, &starts, matched.start());
1332 }
1333 }
1334
1335 fn compiled_regex(pattern: &str) -> CompiledPattern {
1336 match crate::pattern_compile::compile(
1337 pattern,
1338 crate::pattern_compile::CompileOpts::default(),
1339 ) {
1340 crate::pattern_compile::CompileResult::Ok(compiled) => compiled,
1341 other => panic!("compile regex {pattern:?}: {other:?}"),
1342 }
1343 }
1344
1345 fn grep_result_bytes(result: &GrepResult) -> Vec<u8> {
1346 serde_json::to_vec(&serde_json::json!({
1347 "matches": result.matches.iter().map(|matched| serde_json::json!({
1348 "file": matched.file,
1349 "line": matched.line,
1350 "column": matched.column,
1351 "line_text": matched.line_text,
1352 "match_text": matched.match_text,
1353 })).collect::<Vec<_>>(),
1354 "total_matches": result.total_matches,
1355 "files_searched": result.files_searched,
1356 "files_with_matches": result.files_with_matches,
1357 "index_status": result.index_status.as_str(),
1358 "truncated": result.truncated,
1359 "fully_degraded": result.fully_degraded,
1360 "engine_capped": result.engine_capped,
1361 "walk_truncated": result.walk_truncated,
1362 }))
1363 .expect("serialize grep result projection")
1364 }
1365
1366 #[test]
1367 fn multi_root_shared_query_matches_per_root_query_bytes() {
1368 let project = tempfile::tempdir().expect("project");
1369 let root_names = ["api", "cli", "daemon", "worker"];
1370 let roots = root_names
1371 .iter()
1372 .map(|name| {
1373 let root = project.path().join(name);
1374 std::fs::create_dir_all(&root).expect("create root");
1375 std::fs::write(
1376 root.join("service.rs"),
1377 "fn needle_alpha_12() {}\nfn needle_beta_34() {}\n",
1378 )
1379 .expect("write fixture");
1380 std::fs::canonicalize(root).expect("canonicalize root")
1381 })
1382 .collect::<Vec<_>>();
1383 let pattern = compiled_regex(r"needle_(?:alpha|beta)_\d+");
1384 let filters = PathFilters::default();
1385 let params = GrepParams {
1386 include: Vec::new(),
1387 exclude: Vec::new(),
1388 max_results: 100,
1389 path_exclusion: None,
1390 };
1391 let scope = GrepScope {
1392 roots: roots
1393 .iter()
1394 .map(|root| ResolvedRoot {
1395 search_root: root.clone(),
1396 filter_root: project.path().to_path_buf(),
1397 use_index: true,
1398 is_external: false,
1399 })
1400 .collect(),
1401 multi_root: true,
1402 per_root_max: 200,
1403 };
1404 let index =
1405 crate::search_index::SearchIndex::build_with_limit_serial(project.path(), 1_048_576);
1406 let snapshot = index.snapshot();
1407 let expected = merge_grep_results(
1408 scope
1409 .roots
1410 .iter()
1411 .map(|root| {
1412 snapshot
1413 .search_grep_profiled_with_filters(
1414 &pattern,
1415 &filters,
1416 &root.search_root,
1417 scope.per_root_max,
1418 None,
1419 )
1420 .0
1421 })
1422 .collect(),
1423 project.path(),
1424 params.max_results,
1425 );
1426 let ctx = AppContext::new(
1427 Box::new(crate::parser::TreeSitterProvider::new()),
1428 crate::config::Config {
1429 project_root: Some(project.path().to_path_buf()),
1430 ..crate::config::Config::default()
1431 },
1432 );
1433 *ctx.search_index().write().expect("lock search index") = Some(index);
1434
1435 let (actual, phases) =
1436 execute_profiled_with_filters(&ctx, &pattern, &scope, ¶ms, &filters);
1437
1438 assert_eq!(
1439 grep_result_bytes(&actual),
1440 grep_result_bytes(&expected),
1441 "shared query search must preserve the legacy per-root result bytes"
1442 );
1443 assert!(
1444 !phases.query_decomposition.is_zero(),
1445 "the indexed request must record its one-time query decomposition"
1446 );
1447 }
1448
1449 #[test]
1451 #[ignore = "manual release-mode issue #219 multi-root query performance probe"]
1452 fn issue_219_multi_root_query_reuse_perf_probe() {
1453 const ROOTS: usize = 4;
1454 const FILES_PER_ROOT: usize = 1_000;
1455 const SAMPLES: usize = 9;
1456 const ITERATIONS: usize = 300;
1457
1458 let project_root = PathBuf::from("/tmp/aft-issue-219-multi-root");
1459 let mut index = crate::search_index::SearchIndex::new();
1460 let roots = (0..ROOTS)
1461 .map(|root_index| project_root.join(format!("packages/root-{root_index}")))
1462 .collect::<Vec<_>>();
1463 for root in &roots {
1464 for file_index in 0..FILES_PER_ROOT {
1465 index.index_file(
1466 &root.join(format!("src/module-{file_index:04}.ts")),
1467 b"export const indexed_value = 'warm corpus';\n",
1468 );
1469 }
1470 }
1471 let snapshot = index.snapshot();
1472 let pattern =
1473 compiled_regex(r"(?:(?:parse|format|validate)_[A-Za-z0-9_]+_)?issue_219_never_present");
1474 let filters = PathFilters::default();
1475
1476 let per_root_once = || {
1477 for root in &roots {
1478 let result = snapshot
1479 .search_grep_profiled_with_filters(&pattern, &filters, root, 100, None)
1480 .0;
1481 std::hint::black_box(result.total_matches);
1482 }
1483 };
1484 let shared_query_once = || {
1485 let query = decompose_grep_pattern(&pattern);
1486 for root in &roots {
1487 let result = snapshot
1488 .search_grep_profiled_with_filters_and_query(
1489 &pattern, &query, &filters, root, 100, None,
1490 )
1491 .0;
1492 std::hint::black_box(result.total_matches);
1493 }
1494 };
1495
1496 let mut per_root_ns = Vec::with_capacity(SAMPLES);
1497 let mut shared_query_ns = Vec::with_capacity(SAMPLES);
1498 for sample in 0..SAMPLES {
1499 let measure = |operation: &dyn Fn()| {
1500 let started = Instant::now();
1501 for _ in 0..ITERATIONS {
1502 operation();
1503 }
1504 started.elapsed().as_nanos() / ITERATIONS as u128
1505 };
1506 if sample % 2 == 0 {
1507 per_root_ns.push(measure(&per_root_once));
1508 shared_query_ns.push(measure(&shared_query_once));
1509 } else {
1510 shared_query_ns.push(measure(&shared_query_once));
1511 per_root_ns.push(measure(&per_root_once));
1512 }
1513 }
1514 per_root_ns.sort_unstable();
1515 shared_query_ns.sort_unstable();
1516 let per_root_median = per_root_ns[SAMPLES / 2];
1517 let shared_query_median = shared_query_ns[SAMPLES / 2];
1518 let speedup = per_root_median as f64 / shared_query_median as f64;
1519
1520 eprintln!(
1521 "issue #219 multi-root regex query: roots={ROOTS} files_per_root={FILES_PER_ROOT} samples={SAMPLES} iterations={ITERATIONS}"
1522 );
1523 eprintln!("per-root decomposition ns/op samples: {per_root_ns:?}");
1524 eprintln!("shared decomposition ns/op samples: {shared_query_ns:?}");
1525 eprintln!(
1526 "median: per-root={per_root_median}ns shared={shared_query_median}ns speedup={speedup:.2}x"
1527 );
1528 }
1529}