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