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
431 let matches = fallback_search_file(
432 &file.to_path_buf(),
433 pattern,
434 max_results,
435 stop_after,
436 &total_matches,
437 &files_searched,
438 &files_with_matches,
439 &truncated,
440 &engine_capped,
441 );
442
443 GrepResult {
444 total_matches: total_matches.load(Ordering::Relaxed),
445 matches,
446 files_searched: files_searched.load(Ordering::Relaxed),
447 files_with_matches: files_with_matches.load(Ordering::Relaxed),
448 index_status,
449 truncated: truncated.load(Ordering::Relaxed),
450 fully_degraded: false,
451 engine_capped: engine_capped.load(Ordering::Relaxed),
452 walk_truncated: false,
453 }
454}
455
456pub fn merge_grep_results(
457 results: Vec<GrepResult>,
458 project_root: &Path,
459 max_results: usize,
460) -> GrepResult {
461 let mut matches = Vec::new();
462 let mut total_matches = 0usize;
463 let mut files_searched = 0usize;
464 let mut files_with_matches = 0usize;
465 let mut index_status = IndexStatus::Ready;
466 let mut any_child_truncated = false;
467 let mut fully_degraded = false;
468 let mut engine_capped = false;
469 let mut walk_truncated = false;
470 let mut seen_match_keys = HashSet::new();
471
472 for result in results {
473 total_matches += result.total_matches;
474 files_searched += result.files_searched;
475 files_with_matches += result.files_with_matches;
476 index_status = weakest_index_status(index_status, result.index_status);
477 any_child_truncated |= result.truncated;
478 fully_degraded |= result.fully_degraded;
479 engine_capped |= result.engine_capped;
480 walk_truncated |= result.walk_truncated;
481
482 for grep_match in result.matches {
483 let file_key = canonical_key(&grep_match.file);
484 let match_key = (file_key, grep_match.line, grep_match.column);
485 if seen_match_keys.insert(match_key) {
486 matches.push(grep_match);
487 }
488 }
489 }
490
491 sort_grep_matches_by_mtime_desc(&mut matches, project_root);
492 if matches.len() > max_results {
493 matches.truncate(max_results);
494 }
495
496 GrepResult {
497 matches,
498 total_matches,
499 files_searched,
500 files_with_matches,
501 index_status,
502 truncated: any_child_truncated || total_matches > max_results,
503 fully_degraded,
504 engine_capped,
505 walk_truncated,
506 }
507}
508
509fn fallback_project_walk_builder(search_root: &Path) -> WalkBuilder {
510 let mut builder = WalkBuilder::new(search_root);
511 builder
512 .hidden(false)
513 .git_ignore(true)
514 .git_global(true)
515 .git_exclude(true)
516 .add_custom_ignore_filename(".aftignore")
517 .filter_entry(|entry| {
518 let name = entry.file_name().to_string_lossy();
519 if entry.file_type().map_or(false, |ft| ft.is_dir()) {
520 return !matches!(
521 name.as_ref(),
522 "node_modules"
523 | "target"
524 | "venv"
525 | ".venv"
526 | ".git"
527 | "__pycache__"
528 | ".tox"
529 | "dist"
530 | "build"
531 );
532 }
533 true
534 });
535 builder
536}
537
538pub(crate) fn bounded_fallback_walk_files(
540 filter_root: &Path,
541 search_root: &Path,
542 filters: &PathFilters,
543) -> FallbackWalkOutcome {
544 bounded_fallback_walk_files_with_limits(
545 filter_root,
546 search_root,
547 filters,
548 MAX_FALLBACK_WALK_FILES,
549 FALLBACK_WALK_BUDGET,
550 )
551}
552
553fn bounded_fallback_walk_files_with_limits(
554 filter_root: &Path,
555 search_root: &Path,
556 filters: &PathFilters,
557 max_files: usize,
558 budget: Duration,
559) -> FallbackWalkOutcome {
560 let started = Instant::now();
561 let mut files = Vec::new();
562 let mut walk_truncated = false;
563 let mut entries_visited = 0usize;
564 let builder = fallback_project_walk_builder(search_root);
565
566 for entry in builder.build().filter_map(|entry| entry.ok()) {
567 entries_visited += 1;
568 if started.elapsed() >= budget {
569 walk_truncated = true;
570 break;
571 }
572 if !entry
573 .file_type()
574 .map_or(false, |file_type| file_type.is_file())
575 {
576 continue;
577 }
578 let path = entry.into_path();
579 if filters.matches(filter_root, &path) {
580 files.push(path);
581 if files.len() > max_files {
582 walk_truncated = true;
583 files.truncate(max_files);
584 break;
585 }
586 }
587 }
588
589 sort_paths_by_mtime_desc(&mut files, filter_root);
590 FallbackWalkOutcome {
591 files,
592 walk_truncated,
593 entries_visited,
594 }
595}
596
597pub(crate) fn for_each_bounded_fallback_walk_file<F>(
598 filter_root: &Path,
599 search_root: &Path,
600 filters: &PathFilters,
601 project_root: &Path,
602 path_exclusion: Option<GrepPathExclusion>,
603 mut on_file: F,
604) -> bool
605where
606 F: FnMut(&PathBuf),
607{
608 for_each_bounded_fallback_walk_file_with_limits(
609 filter_root,
610 search_root,
611 filters,
612 project_root,
613 path_exclusion,
614 MAX_FALLBACK_WALK_FILES,
615 FALLBACK_WALK_BUDGET,
616 &mut on_file,
617 )
618}
619
620fn for_each_bounded_fallback_walk_file_with_limits<F>(
621 filter_root: &Path,
622 search_root: &Path,
623 filters: &PathFilters,
624 project_root: &Path,
625 path_exclusion: Option<GrepPathExclusion>,
626 max_files: usize,
627 budget: Duration,
628 on_file: &mut F,
629) -> bool
630where
631 F: FnMut(&PathBuf),
632{
633 let started = Instant::now();
634 let mut files_seen = 0usize;
635 let builder = fallback_project_walk_builder(search_root);
636
637 for entry in builder.build().filter_map(|entry| entry.ok()) {
638 if started.elapsed() >= budget {
639 return true;
640 }
641 if !entry
642 .file_type()
643 .map_or(false, |file_type| file_type.is_file())
644 {
645 continue;
646 }
647 let path = entry.into_path();
648 if path_exclusion.is_some_and(|exclude| exclude(&path, project_root)) {
649 continue;
650 }
651 if filters.matches(filter_root, &path) {
652 files_seen += 1;
653 if files_seen > max_files {
654 return true;
655 }
656 on_file(&path);
657 }
658 }
659 false
660}
661
662pub fn weakest_index_status(left: IndexStatus, right: IndexStatus) -> IndexStatus {
663 match (left, right) {
664 (IndexStatus::Disabled, _) | (_, IndexStatus::Disabled) => IndexStatus::Disabled,
665 (IndexStatus::Fallback, _) | (_, IndexStatus::Fallback) => IndexStatus::Fallback,
666 (IndexStatus::Building, _) | (_, IndexStatus::Building) => IndexStatus::Building,
667 (IndexStatus::Ready, IndexStatus::Ready) => IndexStatus::Ready,
668 }
669}
670
671#[doc(hidden)]
673pub fn fallback_grep_bench(
674 project_root: &Path,
675 search_root: &Path,
676 filter_root: &Path,
677 pattern: &CompiledPattern,
678 include: &[String],
679 exclude: &[String],
680 max_results: usize,
681) -> GrepResult {
682 let filters = build_path_filters(include, exclude).unwrap_or_default();
683 fallback_grep(
684 project_root,
685 search_root,
686 filter_root,
687 pattern,
688 &filters,
689 max_results,
690 IndexStatus::Fallback,
691 None,
692 )
693}
694
695fn fallback_grep(
696 project_root: &Path,
697 search_root: &Path,
698 filter_root: &Path,
699 pattern: &CompiledPattern,
700 filters: &PathFilters,
701 max_results: usize,
702 index_status: IndexStatus,
703 path_exclusion: Option<GrepPathExclusion>,
704) -> GrepResult {
705 let total_matches = AtomicUsize::new(0);
706 let files_searched = AtomicUsize::new(0);
707 let files_with_matches = AtomicUsize::new(0);
708 let truncated = AtomicBool::new(false);
709 let engine_capped = AtomicBool::new(false);
710 let stop_after = max_results.saturating_mul(2);
711 let stop_scan = Arc::new(AtomicBool::new(false));
712
713 let mut matches = Vec::new();
714 let mut batch: Vec<PathBuf> = Vec::with_capacity(256);
715
716 let flush_batch = |batch: &mut Vec<PathBuf>, matches: &mut Vec<GrepMatch>| {
717 if batch.is_empty() {
718 return;
719 }
720 let chunk = std::mem::take(batch);
721 let partial: Vec<GrepMatch> = chunk
722 .par_iter()
723 .filter_map(|file| {
724 if stop_scan.load(Ordering::Relaxed) {
725 return None;
726 }
727 let file_matches = fallback_search_file(
728 file,
729 pattern,
730 max_results,
731 stop_after,
732 &total_matches,
733 &files_searched,
734 &files_with_matches,
735 &truncated,
736 &engine_capped,
737 );
738 if truncated.load(Ordering::Relaxed)
739 && total_matches.load(Ordering::Relaxed) >= stop_after
740 {
741 stop_scan.store(true, Ordering::Relaxed);
742 }
743 (!file_matches.is_empty()).then_some(file_matches)
744 })
745 .flatten()
746 .collect();
747 matches.extend(partial);
748 };
749
750 let walk_truncated = for_each_bounded_fallback_walk_file(
751 filter_root,
752 search_root,
753 filters,
754 project_root,
755 path_exclusion,
756 |path| {
757 if stop_scan.load(Ordering::Relaxed) {
758 return;
759 }
760 batch.push(path.clone());
761 if batch.len() >= 256 {
762 flush_batch(&mut batch, &mut matches);
763 }
764 },
765 );
766 flush_batch(&mut batch, &mut matches);
767
768 sort_grep_matches_by_mtime_desc(&mut matches, project_root);
769
770 GrepResult {
771 total_matches: total_matches.load(Ordering::Relaxed),
772 matches,
773 files_searched: files_searched.load(Ordering::Relaxed),
774 files_with_matches: files_with_matches.load(Ordering::Relaxed),
775 index_status,
776 truncated: truncated.load(Ordering::Relaxed),
777 fully_degraded: true,
778 engine_capped: engine_capped.load(Ordering::Relaxed),
779 walk_truncated,
780 }
781}
782
783fn fallback_search_file(
784 file: &PathBuf,
785 pattern: &CompiledPattern,
786 max_results: usize,
787 stop_after: usize,
788 total_matches: &AtomicUsize,
789 files_searched: &AtomicUsize,
790 files_with_matches: &AtomicUsize,
791 truncated: &AtomicBool,
792 engine_capped: &AtomicBool,
793) -> Vec<GrepMatch> {
794 if should_stop_fallback_search(truncated, total_matches, stop_after) {
795 engine_capped.store(true, Ordering::Relaxed);
796 return Vec::new();
797 }
798
799 let Some(content) = read_searchable_text(file) else {
800 return Vec::new();
801 };
802 files_searched.fetch_add(1, Ordering::Relaxed);
803
804 let line_starts = line_starts(&content);
805 let mut seen_lines = HashSet::new();
806 let mut matched_this_file = false;
807 let mut matches = Vec::new();
808
809 match pattern {
810 CompiledPattern::Literal(literal) => search_literal_in_text(
811 file,
812 &content,
813 &line_starts,
814 literal,
815 max_results,
816 stop_after,
817 total_matches,
818 &mut seen_lines,
819 truncated,
820 engine_capped,
821 &mut matched_this_file,
822 &mut matches,
823 ),
824 CompiledPattern::Regex { compiled, .. } => {
825 for matched in compiled.find_iter(content.as_bytes()) {
826 if should_stop_fallback_search(truncated, total_matches, stop_after) {
827 engine_capped.store(true, Ordering::Relaxed);
828 break;
829 }
830
831 let (line, column, line_text) =
832 line_details(&content, &line_starts, matched.start());
833 if !seen_lines.insert(line) {
834 continue;
835 }
836
837 matched_this_file = true;
838 let match_number = total_matches.fetch_add(1, Ordering::Relaxed) + 1;
839 if match_number > max_results {
840 truncated.store(true, Ordering::Relaxed);
841 break;
842 }
843
844 matches.push(GrepMatch {
845 file: file.clone(),
846 line,
847 column,
848 line_text,
849 match_text: String::from_utf8_lossy(matched.as_bytes()).into_owned(),
850 });
851 }
852 }
853 }
854
855 if matched_this_file {
856 files_with_matches.fetch_add(1, Ordering::Relaxed);
857 }
858
859 matches
860}
861
862fn search_literal_in_text(
863 file: &Path,
864 content: &str,
865 line_starts: &[usize],
866 literal: &LiteralSearch,
867 max_results: usize,
868 stop_after: usize,
869 total_matches: &AtomicUsize,
870 seen_lines: &mut HashSet<u32>,
871 truncated: &AtomicBool,
872 engine_capped: &AtomicBool,
873 matched_this_file: &mut bool,
874 matches: &mut Vec<GrepMatch>,
875) {
876 let content_bytes = content.as_bytes();
877 let search_content;
878 let haystack = if literal.case_insensitive_ascii {
879 search_content = content_bytes.to_ascii_lowercase();
880 search_content.as_slice()
881 } else {
882 content_bytes
883 };
884 let finder = memchr::memmem::Finder::new(&literal.needle);
885 let mut start = 0usize;
886
887 while let Some(position) = finder.find(&haystack[start..]) {
888 if should_stop_fallback_search(truncated, total_matches, stop_after) {
889 engine_capped.store(true, Ordering::Relaxed);
890 break;
891 }
892
893 let offset = start + position;
894 start = offset + 1;
895 let (line, column, line_text) = line_details(content, line_starts, offset);
896 if !seen_lines.insert(line) {
897 continue;
898 }
899
900 *matched_this_file = true;
901 let match_number = total_matches.fetch_add(1, Ordering::Relaxed) + 1;
902 if match_number > max_results {
903 truncated.store(true, Ordering::Relaxed);
904 break;
905 }
906
907 let end = offset + literal.needle.len();
908 matches.push(GrepMatch {
909 file: file.to_path_buf(),
910 line,
911 column,
912 line_text,
913 match_text: String::from_utf8_lossy(&content_bytes[offset..end]).into_owned(),
914 });
915 }
916}
917
918fn should_stop_fallback_search(
919 truncated: &AtomicBool,
920 total_matches: &AtomicUsize,
921 stop_after: usize,
922) -> bool {
923 truncated.load(Ordering::Relaxed) && total_matches.load(Ordering::Relaxed) >= stop_after
924}
925
926pub(crate) fn ripgrep_glob(
927 search_root: &Path,
928 pattern: &str,
929 max_results: usize,
930) -> Option<FallbackWalkOutcome> {
931 let filters = build_path_filters(&[pattern.to_string()], &[]).ok()?;
932 let mut outcome = bounded_fallback_walk_files(search_root, search_root, &filters);
933 outcome.files.truncate(max_results);
934 Some(outcome)
935}
936
937fn current_index_status(ctx: &AppContext) -> IndexStatus {
938 let Some(search_index) =
939 try_read_with_budget(ctx.search_index(), INTERACTIVE_ARTIFACT_READ_BUDGET)
940 else {
941 return IndexStatus::Fallback;
942 };
943 if search_index.as_ref().is_some_and(|index| index.ready) {
944 return IndexStatus::Ready;
945 }
946
947 let build_in_progress =
948 try_read_with_budget(ctx.search_index_rx(), INTERACTIVE_ARTIFACT_READ_BUDGET)
949 .is_some_and(|search_index_rx| search_index_rx.is_some());
950 if build_in_progress || search_index.is_some() {
951 IndexStatus::Building
952 } else {
953 IndexStatus::Fallback
954 }
955}
956
957pub fn line_starts(content: &str) -> Vec<usize> {
958 let mut starts = vec![0usize];
959 for (index, byte) in content.bytes().enumerate() {
960 if byte == b'\n' {
961 starts.push(index + 1);
962 }
963 }
964 starts
965}
966
967pub fn floor_char_boundary_str(content: &str, mut index: usize) -> usize {
969 index = index.min(content.len());
970 while index > 0 && !content.is_char_boundary(index) {
971 index -= 1;
972 }
973 index
974}
975
976pub fn truncate_at_char_boundary(content: &str, max_bytes: usize) -> &str {
978 let end = floor_char_boundary_str(content, max_bytes);
979 &content[..end]
980}
981
982pub fn line_details(content: &str, line_starts: &[usize], offset: usize) -> (u32, u32, String) {
983 let offset = floor_char_boundary_str(content, offset);
984 let line_index = match line_starts.binary_search(&offset) {
985 Ok(index) => index,
986 Err(index) => index.saturating_sub(1),
987 };
988 let line_start = line_starts.get(line_index).copied().unwrap_or(0);
989 let line_end = content[line_start..]
990 .find('\n')
991 .map(|length| line_start + length)
992 .unwrap_or(content.len());
993 let line_text = content[line_start..line_end]
994 .trim_end_matches('\r')
995 .to_string();
996 let column = content[line_start..offset].chars().count() as u32 + 1;
997 (line_index as u32 + 1, column, line_text)
998}
999
1000#[cfg(test)]
1001mod tests {
1002 use super::*;
1003
1004 fn grep_match(file: &Path, line: u32, column: u32) -> GrepMatch {
1005 GrepMatch {
1006 file: file.to_path_buf(),
1007 line,
1008 column,
1009 line_text: "needle".to_string(),
1010 match_text: "needle".to_string(),
1011 }
1012 }
1013
1014 fn result(matches: Vec<GrepMatch>, truncated: bool, status: IndexStatus) -> GrepResult {
1015 GrepResult {
1016 total_matches: matches.len(),
1017 files_searched: matches.len(),
1018 files_with_matches: matches.len(),
1019 matches,
1020 index_status: status,
1021 truncated,
1022 fully_degraded: false,
1023 engine_capped: false,
1024 walk_truncated: false,
1025 }
1026 }
1027
1028 #[test]
1029 fn optional_path_exclusion_controls_visible_totals_without_affecting_default_grep() {
1030 fn excludes_tests(path: &Path, root: &Path) -> bool {
1031 path.strip_prefix(root)
1032 .is_ok_and(|relative| relative.starts_with("tests"))
1033 }
1034
1035 let project = tempfile::tempdir().expect("project");
1036 let test_file = project.path().join("tests/case.rs");
1037 let source_file = project.path().join("src/lib.rs");
1038 std::fs::create_dir_all(test_file.parent().expect("test parent")).expect("test dir");
1039 std::fs::create_dir_all(source_file.parent().expect("source parent")).expect("source dir");
1040 std::fs::write(&test_file, "const NEEDLE: &str = \"needle\";\n").expect("test file");
1041 std::fs::write(&source_file, "pub fn needle() {}\n").expect("source file");
1042 let pattern = match crate::pattern_compile::compile(
1043 "needle",
1044 crate::pattern_compile::CompileOpts {
1045 literal: true,
1046 ..crate::pattern_compile::CompileOpts::default()
1047 },
1048 ) {
1049 crate::pattern_compile::CompileResult::Ok(pattern) => pattern,
1050 other => panic!("compile literal: {other:?}"),
1051 };
1052
1053 let filters = PathFilters::default();
1054 let unfiltered = fallback_grep(
1055 project.path(),
1056 project.path(),
1057 project.path(),
1058 &pattern,
1059 &filters,
1060 10,
1061 IndexStatus::Fallback,
1062 None,
1063 );
1064 assert_eq!(unfiltered.total_matches, 2);
1065 assert_eq!(unfiltered.matches.len(), 2);
1066
1067 let visible = fallback_grep(
1068 project.path(),
1069 project.path(),
1070 project.path(),
1071 &pattern,
1072 &filters,
1073 10,
1074 IndexStatus::Fallback,
1075 Some(excludes_tests),
1076 );
1077 assert_eq!(visible.total_matches, 1);
1078 assert_eq!(visible.matches.len(), 1);
1079 assert_eq!(visible.files_searched, 1);
1080 assert_eq!(visible.files_with_matches, 1);
1081 assert_eq!(visible.matches[0].file, source_file);
1082 assert!(!visible.truncated);
1083 assert!(!visible.engine_capped);
1084 }
1085
1086 #[test]
1087 fn single_root_uses_requested_max() {
1088 let scope = GrepScope {
1089 roots: vec![ResolvedRoot {
1090 search_root: PathBuf::from("/project"),
1091 filter_root: PathBuf::from("/project"),
1092 use_index: true,
1093 is_external: false,
1094 }],
1095 multi_root: false,
1096 per_root_max: 10,
1097 };
1098 assert!(!scope.multi_root);
1099 assert_eq!(scope.per_root_max, 10);
1100 }
1101
1102 #[test]
1103 fn multi_root_uses_double_per_root_max() {
1104 let project = tempfile::tempdir().expect("project");
1105 let ctx = AppContext::new(
1106 Box::new(crate::parser::TreeSitterProvider::new()),
1107 crate::config::Config {
1108 project_root: Some(project.path().to_path_buf()),
1109 ..crate::config::Config::default()
1110 },
1111 );
1112 let left = project.path().join("left");
1113 let right = project.path().join("right");
1114 std::fs::create_dir_all(&left).expect("left");
1115 std::fs::create_dir_all(&right).expect("right");
1116 let paths = serde_json::json!([left.display().to_string(), right.display().to_string()]);
1117
1118 let scope = resolve_grep_scope(&ctx, Some(&paths), 10, "test").expect("scope");
1119
1120 assert!(scope.multi_root);
1121 assert_eq!(scope.per_root_max, 20);
1122 }
1123
1124 #[test]
1125 fn bounded_fallback_walk_truncates_at_file_cap() {
1126 let dir = tempfile::tempdir().expect("tempdir");
1127 let root = dir.path();
1128 for i in 0..25 {
1129 let path = root.join(format!("file_{i:03}.txt"));
1130 std::fs::write(path, "needle\n").expect("write");
1131 }
1132 let filters = build_path_filters(&["**/*.txt".to_string()], &[]).expect("filters");
1133 let outcome = bounded_fallback_walk_files_with_limits(
1134 root,
1135 root,
1136 &filters,
1137 20,
1138 Duration::from_secs(60),
1139 );
1140 assert!(outcome.walk_truncated);
1141 assert_eq!(outcome.files.len(), 20);
1142 }
1143
1144 #[test]
1145 fn bounded_fallback_walk_small_tree_not_truncated() {
1146 let dir = tempfile::tempdir().expect("tempdir");
1147 let root = dir.path();
1148 std::fs::write(root.join("a.txt"), "x\n").expect("write");
1149 std::fs::write(root.join("b.txt"), "x\n").expect("write");
1150 let filters = build_path_filters(&["**/*.txt".to_string()], &[]).expect("filters");
1151 let outcome = bounded_fallback_walk_files(root, root, &filters);
1152 assert!(!outcome.walk_truncated);
1153 assert_eq!(outcome.files.len(), 2);
1154 }
1155
1156 #[test]
1157 fn filter_root_is_project_for_in_project_and_search_root_for_external_unindexed() {
1158 let project = PathBuf::from("/project");
1159 let in_project = compute_filter_root(&project, Path::new("/project/src"), true, false);
1160 let external = compute_filter_root(&project, Path::new("/tmp/external"), false, true);
1161 assert_eq!(in_project, project);
1162 assert_eq!(external, PathBuf::from("/tmp/external"));
1163 }
1164
1165 #[test]
1166 fn weakest_status_orders_disabled_fallback_building_ready() {
1167 assert_eq!(
1168 weakest_index_status(IndexStatus::Ready, IndexStatus::Building),
1169 IndexStatus::Building
1170 );
1171 assert_eq!(
1172 weakest_index_status(IndexStatus::Building, IndexStatus::Fallback),
1173 IndexStatus::Fallback
1174 );
1175 assert_eq!(
1176 weakest_index_status(IndexStatus::Fallback, IndexStatus::Disabled),
1177 IndexStatus::Disabled
1178 );
1179 }
1180
1181 #[test]
1182 fn merge_dedupes_by_canonical_file_line_column() {
1183 let temp = tempfile::tempdir().expect("temp");
1184 let file = temp.path().join("file.rs");
1185 std::fs::write(&file, "needle").expect("write");
1186 let symlink = temp.path().join("link.rs");
1187 #[cfg(unix)]
1188 std::os::unix::fs::symlink(&file, &symlink).expect("symlink");
1189 #[cfg(windows)]
1190 std::os::windows::fs::symlink_file(&file, &symlink).expect("symlink");
1191
1192 let merged = merge_grep_results(
1193 vec![
1194 result(vec![grep_match(&file, 1, 1)], false, IndexStatus::Ready),
1195 result(vec![grep_match(&symlink, 1, 1)], false, IndexStatus::Ready),
1196 ],
1197 temp.path(),
1198 10,
1199 );
1200
1201 assert_eq!(merged.matches.len(), 1);
1202 }
1203
1204 #[test]
1205 fn merge_truncated_when_child_truncated_or_pre_merge_exceeds_max() {
1206 let root = Path::new("/project");
1207 let child = merge_grep_results(
1208 vec![result(
1209 vec![grep_match(Path::new("/project/a.rs"), 1, 1)],
1210 true,
1211 IndexStatus::Ready,
1212 )],
1213 root,
1214 10,
1215 );
1216 assert!(child.truncated);
1217
1218 let many = merge_grep_results(
1219 vec![
1220 result(
1221 vec![grep_match(Path::new("/project/a.rs"), 1, 1)],
1222 false,
1223 IndexStatus::Ready,
1224 ),
1225 result(
1226 vec![grep_match(Path::new("/project/b.rs"), 1, 1)],
1227 false,
1228 IndexStatus::Ready,
1229 ),
1230 ],
1231 root,
1232 1,
1233 );
1234 assert!(many.truncated);
1235 }
1236
1237 #[test]
1238 fn line_details_floors_offset_inside_multibyte_char() {
1239 let content = "before—after";
1240 let starts = line_starts(content);
1241 let dash_byte = content.find('—').expect("em dash");
1242 let mid_byte = dash_byte + 1;
1243 assert!(!content.is_char_boundary(mid_byte));
1244 let (line, column, line_text) = line_details(content, &starts, mid_byte);
1245 assert_eq!(line, 1);
1246 assert_eq!(column, content[..dash_byte].chars().count() as u32 + 1);
1247 assert!(line_text.contains('—'));
1248 }
1249
1250 #[test]
1251 fn line_details_clamps_offset_past_end() {
1252 let content = "short";
1253 let starts = line_starts(content);
1254 let (line, column, _) = line_details(content, &starts, content.len() + 100);
1255 assert_eq!(line, 1);
1256 assert_eq!(column, 6);
1257 }
1258
1259 #[test]
1260 fn truncate_at_char_boundary_floors_mid_multibyte_at_byte_cap() {
1261 let mut prefix = "a".repeat(38);
1262 prefix.push('—');
1263 prefix.push_str("tail");
1264 assert_eq!(prefix.len(), 45);
1265 assert!(!prefix.is_char_boundary(40));
1266 let truncated = truncate_at_char_boundary(&prefix, 40);
1267 assert!(truncated.is_char_boundary(truncated.len()));
1268 assert!(truncated.ends_with('a'));
1269 assert!(!truncated.contains('—'));
1270 }
1271
1272 #[test]
1273 fn regex_byte_match_start_mid_char_does_not_panic_in_line_details() {
1274 use crate::pattern_compile::{CompileOpts, CompileResult};
1275
1276 let content = "xy—zz";
1277 let starts = line_starts(content);
1278 let compiled = match crate::pattern_compile::compile(
1279 ".",
1280 CompileOpts {
1281 multi_line: false,
1282 ..CompileOpts::default()
1283 },
1284 ) {
1285 CompileResult::Ok(compiled) => compiled,
1286 other => panic!("expected compiled pattern, got {other:?}"),
1287 };
1288 let crate::pattern_compile::CompiledPattern::Regex { compiled, .. } = compiled else {
1289 panic!("expected regex pattern");
1290 };
1291 for matched in compiled.find_iter(content.as_bytes()) {
1292 let _ = line_details(content, &starts, matched.start());
1293 }
1294 }
1295
1296 fn compiled_regex(pattern: &str) -> CompiledPattern {
1297 match crate::pattern_compile::compile(
1298 pattern,
1299 crate::pattern_compile::CompileOpts::default(),
1300 ) {
1301 crate::pattern_compile::CompileResult::Ok(compiled) => compiled,
1302 other => panic!("compile regex {pattern:?}: {other:?}"),
1303 }
1304 }
1305
1306 fn grep_result_bytes(result: &GrepResult) -> Vec<u8> {
1307 serde_json::to_vec(&serde_json::json!({
1308 "matches": result.matches.iter().map(|matched| serde_json::json!({
1309 "file": matched.file,
1310 "line": matched.line,
1311 "column": matched.column,
1312 "line_text": matched.line_text,
1313 "match_text": matched.match_text,
1314 })).collect::<Vec<_>>(),
1315 "total_matches": result.total_matches,
1316 "files_searched": result.files_searched,
1317 "files_with_matches": result.files_with_matches,
1318 "index_status": result.index_status.as_str(),
1319 "truncated": result.truncated,
1320 "fully_degraded": result.fully_degraded,
1321 "engine_capped": result.engine_capped,
1322 "walk_truncated": result.walk_truncated,
1323 }))
1324 .expect("serialize grep result projection")
1325 }
1326
1327 #[test]
1328 fn multi_root_shared_query_matches_per_root_query_bytes() {
1329 let project = tempfile::tempdir().expect("project");
1330 let root_names = ["api", "cli", "daemon", "worker"];
1331 let roots = root_names
1332 .iter()
1333 .map(|name| {
1334 let root = project.path().join(name);
1335 std::fs::create_dir_all(&root).expect("create root");
1336 std::fs::write(
1337 root.join("service.rs"),
1338 "fn needle_alpha_12() {}\nfn needle_beta_34() {}\n",
1339 )
1340 .expect("write fixture");
1341 std::fs::canonicalize(root).expect("canonicalize root")
1342 })
1343 .collect::<Vec<_>>();
1344 let pattern = compiled_regex(r"needle_(?:alpha|beta)_\d+");
1345 let filters = PathFilters::default();
1346 let params = GrepParams {
1347 include: Vec::new(),
1348 exclude: Vec::new(),
1349 max_results: 100,
1350 path_exclusion: None,
1351 };
1352 let scope = GrepScope {
1353 roots: roots
1354 .iter()
1355 .map(|root| ResolvedRoot {
1356 search_root: root.clone(),
1357 filter_root: project.path().to_path_buf(),
1358 use_index: true,
1359 is_external: false,
1360 })
1361 .collect(),
1362 multi_root: true,
1363 per_root_max: 200,
1364 };
1365 let index =
1366 crate::search_index::SearchIndex::build_with_limit_serial(project.path(), 1_048_576);
1367 let snapshot = index.snapshot();
1368 let expected = merge_grep_results(
1369 scope
1370 .roots
1371 .iter()
1372 .map(|root| {
1373 snapshot
1374 .search_grep_profiled_with_filters(
1375 &pattern,
1376 &filters,
1377 &root.search_root,
1378 scope.per_root_max,
1379 None,
1380 )
1381 .0
1382 })
1383 .collect(),
1384 project.path(),
1385 params.max_results,
1386 );
1387 let ctx = AppContext::new(
1388 Box::new(crate::parser::TreeSitterProvider::new()),
1389 crate::config::Config {
1390 project_root: Some(project.path().to_path_buf()),
1391 ..crate::config::Config::default()
1392 },
1393 );
1394 *ctx.search_index().write().expect("lock search index") = Some(index);
1395
1396 let (actual, phases) =
1397 execute_profiled_with_filters(&ctx, &pattern, &scope, ¶ms, &filters);
1398
1399 assert_eq!(
1400 grep_result_bytes(&actual),
1401 grep_result_bytes(&expected),
1402 "shared query search must preserve the legacy per-root result bytes"
1403 );
1404 assert!(
1405 !phases.query_decomposition.is_zero(),
1406 "the indexed request must record its one-time query decomposition"
1407 );
1408 }
1409
1410 #[test]
1412 #[ignore = "manual release-mode issue #219 multi-root query performance probe"]
1413 fn issue_219_multi_root_query_reuse_perf_probe() {
1414 const ROOTS: usize = 4;
1415 const FILES_PER_ROOT: usize = 1_000;
1416 const SAMPLES: usize = 9;
1417 const ITERATIONS: usize = 300;
1418
1419 let project_root = PathBuf::from("/tmp/aft-issue-219-multi-root");
1420 let mut index = crate::search_index::SearchIndex::new();
1421 let roots = (0..ROOTS)
1422 .map(|root_index| project_root.join(format!("packages/root-{root_index}")))
1423 .collect::<Vec<_>>();
1424 for root in &roots {
1425 for file_index in 0..FILES_PER_ROOT {
1426 index.index_file(
1427 &root.join(format!("src/module-{file_index:04}.ts")),
1428 b"export const indexed_value = 'warm corpus';\n",
1429 );
1430 }
1431 }
1432 let snapshot = index.snapshot();
1433 let pattern =
1434 compiled_regex(r"(?:(?:parse|format|validate)_[A-Za-z0-9_]+_)?issue_219_never_present");
1435 let filters = PathFilters::default();
1436
1437 let per_root_once = || {
1438 for root in &roots {
1439 let result = snapshot
1440 .search_grep_profiled_with_filters(&pattern, &filters, root, 100, None)
1441 .0;
1442 std::hint::black_box(result.total_matches);
1443 }
1444 };
1445 let shared_query_once = || {
1446 let query = decompose_grep_pattern(&pattern);
1447 for root in &roots {
1448 let result = snapshot
1449 .search_grep_profiled_with_filters_and_query(
1450 &pattern, &query, &filters, root, 100, None,
1451 )
1452 .0;
1453 std::hint::black_box(result.total_matches);
1454 }
1455 };
1456
1457 let mut per_root_ns = Vec::with_capacity(SAMPLES);
1458 let mut shared_query_ns = Vec::with_capacity(SAMPLES);
1459 for sample in 0..SAMPLES {
1460 let measure = |operation: &dyn Fn()| {
1461 let started = Instant::now();
1462 for _ in 0..ITERATIONS {
1463 operation();
1464 }
1465 started.elapsed().as_nanos() / ITERATIONS as u128
1466 };
1467 if sample % 2 == 0 {
1468 per_root_ns.push(measure(&per_root_once));
1469 shared_query_ns.push(measure(&shared_query_once));
1470 } else {
1471 shared_query_ns.push(measure(&shared_query_once));
1472 per_root_ns.push(measure(&per_root_once));
1473 }
1474 }
1475 per_root_ns.sort_unstable();
1476 shared_query_ns.sort_unstable();
1477 let per_root_median = per_root_ns[SAMPLES / 2];
1478 let shared_query_median = shared_query_ns[SAMPLES / 2];
1479 let speedup = per_root_median as f64 / shared_query_median as f64;
1480
1481 eprintln!(
1482 "issue #219 multi-root regex query: roots={ROOTS} files_per_root={FILES_PER_ROOT} samples={SAMPLES} iterations={ITERATIONS}"
1483 );
1484 eprintln!("per-root decomposition ns/op samples: {per_root_ns:?}");
1485 eprintln!("shared decomposition ns/op samples: {shared_query_ns:?}");
1486 eprintln!(
1487 "median: per-root={per_root_median}ns shared={shared_query_median}ns speedup={speedup:.2}x"
1488 );
1489 }
1490}