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, has_any_project_file_from, read_searchable_text, resolve_search_scope,
19 sort_grep_matches_by_mtime_desc, sort_paths_by_mtime_desc, GrepMatch, GrepPathExclusion,
20 GrepQueryPhaseTimings, GrepResult, IndexStatus, PathFilters,
21};
22
23pub(crate) const MAX_FALLBACK_WALK_FILES: usize = 50_000;
25pub(crate) const FALLBACK_WALK_BUDGET: Duration = Duration::from_secs(10);
27
28#[derive(Clone, Debug)]
29pub struct FallbackWalkOutcome {
30 pub files: Vec<PathBuf>,
31 pub walk_truncated: bool,
32 pub entries_visited: usize,
33}
34
35#[derive(Clone, Debug)]
36pub struct GrepParams {
37 pub include: Vec<String>,
38 pub exclude: Vec<String>,
39 pub max_results: usize,
40 pub path_exclusion: Option<GrepPathExclusion>,
41}
42
43#[derive(Clone, Copy, Debug, Default)]
44pub(crate) struct GrepExecutionPhaseTimings {
45 pub snapshot_acquire: Duration,
46 pub query: GrepQueryPhaseTimings,
47 pub indexed_scope_has_files: Option<bool>,
48}
49
50impl GrepExecutionPhaseTimings {
51 fn add(&mut self, other: Self) {
52 self.snapshot_acquire += other.snapshot_acquire;
53 self.query.trigram_lookup += other.query.trigram_lookup;
54 self.query.pread_verify += other.query.pread_verify;
55 self.query.post_filter += other.query.post_filter;
56 self.query.candidate_count += other.query.candidate_count;
57 self.query.bytes_verified += other.query.bytes_verified;
58 self.indexed_scope_has_files =
59 match (self.indexed_scope_has_files, other.indexed_scope_has_files) {
60 (Some(left), Some(right)) => Some(left || right),
61 _ => None,
62 };
63 }
64}
65
66#[derive(Clone, Debug)]
67pub struct GrepScope {
68 pub roots: Vec<ResolvedRoot>,
69 pub multi_root: bool,
70 pub per_root_max: usize,
71}
72
73#[derive(Clone, Debug)]
74pub struct ResolvedRoot {
75 pub search_root: PathBuf,
76 pub filter_root: PathBuf,
77 pub use_index: bool,
78 pub is_external: bool,
79}
80
81pub fn project_root(ctx: &AppContext) -> PathBuf {
82 let project_root = ctx
83 .config()
84 .project_root
85 .clone()
86 .unwrap_or_else(|| env::current_dir().unwrap_or_default());
87 std::fs::canonicalize(&project_root).unwrap_or(project_root)
88}
89
90pub fn resolve_grep_scope(
91 ctx: &AppContext,
92 paths: Option<&serde_json::Value>,
93 max_results: usize,
94 req_id: &str,
95) -> Result<GrepScope, Response> {
96 let project_root = project_root(ctx);
97 let search_roots = resolve_roots(ctx, paths, &project_root, req_id)?;
98
99 if let Some(missing_root) = search_roots.iter().find(|root| !root.exists()) {
100 return Err(Response::error(
101 req_id,
102 "path_not_found",
103 format!(
104 "grep: search path does not exist: {}",
105 missing_root.display()
106 ),
107 ));
108 }
109
110 let roots = search_roots
111 .into_iter()
112 .map(|search_root| {
113 let scope = resolve_search_scope(&project_root, Some(&search_root.to_string_lossy()));
114 let is_external = !scope.use_index;
115 let filter_root =
116 compute_filter_root(&project_root, &scope.root, scope.use_index, is_external);
117 ResolvedRoot {
118 search_root: scope.root,
119 filter_root,
120 use_index: scope.use_index,
121 is_external,
122 }
123 })
124 .collect::<Vec<_>>();
125
126 let multi_root = roots.len() > 1;
127 let per_root_max = if multi_root {
128 max_results.saturating_mul(2).max(max_results)
129 } else {
130 max_results
131 };
132
133 Ok(GrepScope {
134 roots,
135 multi_root,
136 per_root_max,
137 })
138}
139
140pub fn compute_filter_root(
141 project_root: &Path,
142 search_root: &Path,
143 use_index: bool,
144 is_external: bool,
145) -> PathBuf {
146 if is_external && !use_index {
147 search_root.to_path_buf()
148 } else {
149 project_root.to_path_buf()
150 }
151}
152
153pub fn scope_has_files(project_root: &Path, scope: &GrepScope) -> bool {
154 scope.roots.iter().any(|root| {
155 if root.search_root.is_file() {
159 return true;
160 }
161 let catch_all =
162 build_path_filters(&["**/*".to_string()], &[]).expect("valid catch-all glob");
163 has_any_project_file_from(&root.filter_root, &root.search_root, &catch_all)
164 || has_any_project_file_from(project_root, &root.search_root, &catch_all)
165 })
166}
167
168pub fn execute(
169 ctx: &AppContext,
170 pattern: &CompiledPattern,
171 scope: &GrepScope,
172 params: &GrepParams,
173) -> GrepResult {
174 execute_profiled(ctx, pattern, scope, params).0
175}
176
177pub(crate) fn execute_profiled(
178 ctx: &AppContext,
179 pattern: &CompiledPattern,
180 scope: &GrepScope,
181 params: &GrepParams,
182) -> (GrepResult, GrepExecutionPhaseTimings) {
183 let project_root = project_root(ctx);
184 if scope.roots.len() == 1 {
185 return execute_root_profiled(
186 ctx,
187 pattern,
188 &scope.roots[0],
189 params,
190 params.max_results,
191 &project_root,
192 );
193 }
194
195 let mut results = Vec::new();
196 let mut phases: Option<GrepExecutionPhaseTimings> = None;
197 for root in &scope.roots {
198 let (result, root_phases) = execute_root_profiled(
199 ctx,
200 pattern,
201 root,
202 params,
203 scope.per_root_max,
204 &project_root,
205 );
206 results.push(result);
207 if let Some(phases) = phases.as_mut() {
208 phases.add(root_phases);
209 } else {
210 phases = Some(root_phases);
211 }
212 }
213 (
214 merge_grep_results(results, &project_root, params.max_results),
215 phases.unwrap_or_default(),
216 )
217}
218
219fn resolve_roots(
220 ctx: &AppContext,
221 paths: Option<&serde_json::Value>,
222 project_root: &Path,
223 req_id: &str,
224) -> Result<Vec<PathBuf>, Response> {
225 let Some(paths) = paths else {
226 return Ok(vec![resolve_search_scope(project_root, None).root]);
227 };
228 if paths.is_null() {
229 return Ok(vec![resolve_search_scope(project_root, None).root]);
230 }
231 if let Some(path) = paths.as_str() {
232 return match resolve_path_or_multi(
233 path,
234 project_root,
235 |candidate| ctx.validate_path(req_id, candidate),
236 req_id,
237 )? {
238 SearchPathResolution::Single(root) => Ok(vec![root]),
239 SearchPathResolution::Multi(roots) => Ok(roots),
240 };
241 }
242 if let Some(items) = paths.as_array() {
243 let mut roots = Vec::with_capacity(items.len());
244 for item in items {
245 let Some(path) = item.as_str() else {
246 return Err(Response::error(
247 req_id,
248 "invalid_request",
249 "grep: path array entries must be strings",
250 ));
251 };
252 let validated = ctx.validate_path(req_id, Path::new(path))?;
253 let raw = validated.to_string_lossy();
254 roots.push(resolve_search_scope(project_root, Some(raw.as_ref())).root);
255 }
256 let roots = dedupe_nested_paths(roots);
257 if roots.is_empty() {
258 Ok(vec![resolve_search_scope(project_root, None).root])
259 } else {
260 Ok(roots)
261 }
262 } else {
263 Err(Response::error(
264 req_id,
265 "invalid_request",
266 "grep: path must be a string, array of strings, or null",
267 ))
268 }
269}
270
271fn execute_root_profiled(
272 ctx: &AppContext,
273 pattern: &CompiledPattern,
274 root: &ResolvedRoot,
275 params: &GrepParams,
276 max_results: usize,
277 project_root: &Path,
278) -> (GrepResult, GrepExecutionPhaseTimings) {
279 if root.search_root.is_file() {
284 if root.use_index {
285 crate::commands::configure::trigger_search_index_reload_if_evicted(ctx);
286 }
287 let index_status = if root.use_index {
288 current_index_status(ctx)
289 } else {
290 IndexStatus::Fallback
291 };
292 let result = if params
293 .path_exclusion
294 .is_some_and(|exclude| exclude(&root.search_root, project_root))
295 {
296 empty_grep_result(index_status, false)
297 } else {
298 grep_explicit_file(&root.search_root, pattern, max_results, index_status)
299 };
300 return (result, GrepExecutionPhaseTimings::default());
301 }
302
303 let snapshot_started = Instant::now();
304 let indexed_snapshot = {
305 let search_index = ctx
306 .search_index()
307 .read()
308 .unwrap_or_else(std::sync::PoisonError::into_inner);
309 match search_index.as_ref() {
310 Some(index) if index.ready && root.use_index => Some(index.snapshot()),
311 _ => None,
312 }
313 };
314 let snapshot_acquire = snapshot_started.elapsed();
315 if let Some(snapshot) = indexed_snapshot {
316 let scope_started = Instant::now();
317 let indexed_scope_has_files = snapshot.has_file_in_scope(&root.search_root);
318 let scope_elapsed = scope_started.elapsed();
319 let (result, mut query) = snapshot.search_grep_profiled(
320 pattern,
321 ¶ms.include,
322 ¶ms.exclude,
323 &root.search_root,
324 max_results,
325 params.path_exclusion,
326 );
327 query.post_filter += scope_elapsed;
328 return (
329 result,
330 GrepExecutionPhaseTimings {
331 snapshot_acquire,
332 query,
333 indexed_scope_has_files: Some(indexed_scope_has_files),
334 },
335 );
336 }
337
338 if root.use_index {
339 crate::commands::configure::trigger_search_index_reload_if_evicted(ctx);
340 }
341 let index_status = if root.use_index {
342 current_index_status(ctx)
343 } else {
344 IndexStatus::Fallback
345 };
346 (
347 fallback_grep(
348 project_root,
349 &root.search_root,
350 &root.filter_root,
351 pattern,
352 ¶ms.include,
353 ¶ms.exclude,
354 max_results,
355 index_status,
356 params.path_exclusion,
357 ),
358 GrepExecutionPhaseTimings {
359 snapshot_acquire,
360 ..GrepExecutionPhaseTimings::default()
361 },
362 )
363}
364
365fn empty_grep_result(index_status: IndexStatus, fully_degraded: bool) -> GrepResult {
366 GrepResult {
367 matches: Vec::new(),
368 total_matches: 0,
369 files_searched: 0,
370 files_with_matches: 0,
371 index_status,
372 truncated: false,
373 fully_degraded,
374 engine_capped: false,
375 walk_truncated: false,
376 }
377}
378
379fn grep_explicit_file(
386 file: &Path,
387 pattern: &CompiledPattern,
388 max_results: usize,
389 index_status: IndexStatus,
390) -> GrepResult {
391 let total_matches = AtomicUsize::new(0);
392 let files_searched = AtomicUsize::new(0);
393 let files_with_matches = AtomicUsize::new(0);
394 let truncated = AtomicBool::new(false);
395 let engine_capped = AtomicBool::new(false);
396 let stop_after = max_results.saturating_mul(2);
397
398 let matches = fallback_search_file(
399 &file.to_path_buf(),
400 pattern,
401 max_results,
402 stop_after,
403 &total_matches,
404 &files_searched,
405 &files_with_matches,
406 &truncated,
407 &engine_capped,
408 );
409
410 GrepResult {
411 total_matches: total_matches.load(Ordering::Relaxed),
412 matches,
413 files_searched: files_searched.load(Ordering::Relaxed),
414 files_with_matches: files_with_matches.load(Ordering::Relaxed),
415 index_status,
416 truncated: truncated.load(Ordering::Relaxed),
417 fully_degraded: false,
418 engine_capped: engine_capped.load(Ordering::Relaxed),
419 walk_truncated: false,
420 }
421}
422
423pub fn merge_grep_results(
424 results: Vec<GrepResult>,
425 project_root: &Path,
426 max_results: usize,
427) -> GrepResult {
428 let mut matches = Vec::new();
429 let mut total_matches = 0usize;
430 let mut files_searched = 0usize;
431 let mut files_with_matches = 0usize;
432 let mut index_status = IndexStatus::Ready;
433 let mut any_child_truncated = false;
434 let mut fully_degraded = false;
435 let mut engine_capped = false;
436 let mut walk_truncated = false;
437 let mut seen_match_keys = HashSet::new();
438
439 for result in results {
440 total_matches += result.total_matches;
441 files_searched += result.files_searched;
442 files_with_matches += result.files_with_matches;
443 index_status = weakest_index_status(index_status, result.index_status);
444 any_child_truncated |= result.truncated;
445 fully_degraded |= result.fully_degraded;
446 engine_capped |= result.engine_capped;
447 walk_truncated |= result.walk_truncated;
448
449 for grep_match in result.matches {
450 let file_key = canonical_key(&grep_match.file);
451 let match_key = (file_key, grep_match.line, grep_match.column);
452 if seen_match_keys.insert(match_key) {
453 matches.push(grep_match);
454 }
455 }
456 }
457
458 sort_grep_matches_by_mtime_desc(&mut matches, project_root);
459 if matches.len() > max_results {
460 matches.truncate(max_results);
461 }
462
463 GrepResult {
464 matches,
465 total_matches,
466 files_searched,
467 files_with_matches,
468 index_status,
469 truncated: any_child_truncated || total_matches > max_results,
470 fully_degraded,
471 engine_capped,
472 walk_truncated,
473 }
474}
475
476fn fallback_project_walk_builder(search_root: &Path) -> WalkBuilder {
477 let mut builder = WalkBuilder::new(search_root);
478 builder
479 .hidden(false)
480 .git_ignore(true)
481 .git_global(true)
482 .git_exclude(true)
483 .add_custom_ignore_filename(".aftignore")
484 .filter_entry(|entry| {
485 let name = entry.file_name().to_string_lossy();
486 if entry.file_type().map_or(false, |ft| ft.is_dir()) {
487 return !matches!(
488 name.as_ref(),
489 "node_modules"
490 | "target"
491 | "venv"
492 | ".venv"
493 | ".git"
494 | "__pycache__"
495 | ".tox"
496 | "dist"
497 | "build"
498 );
499 }
500 true
501 });
502 builder
503}
504
505pub(crate) fn bounded_fallback_walk_files(
507 filter_root: &Path,
508 search_root: &Path,
509 filters: &PathFilters,
510) -> FallbackWalkOutcome {
511 bounded_fallback_walk_files_with_limits(
512 filter_root,
513 search_root,
514 filters,
515 MAX_FALLBACK_WALK_FILES,
516 FALLBACK_WALK_BUDGET,
517 )
518}
519
520fn bounded_fallback_walk_files_with_limits(
521 filter_root: &Path,
522 search_root: &Path,
523 filters: &PathFilters,
524 max_files: usize,
525 budget: Duration,
526) -> FallbackWalkOutcome {
527 let started = Instant::now();
528 let mut files = Vec::new();
529 let mut walk_truncated = false;
530 let mut entries_visited = 0usize;
531 let builder = fallback_project_walk_builder(search_root);
532
533 for entry in builder.build().filter_map(|entry| entry.ok()) {
534 entries_visited += 1;
535 if started.elapsed() >= budget {
536 walk_truncated = true;
537 break;
538 }
539 if !entry
540 .file_type()
541 .map_or(false, |file_type| file_type.is_file())
542 {
543 continue;
544 }
545 let path = entry.into_path();
546 if filters.matches(filter_root, &path) {
547 files.push(path);
548 if files.len() > max_files {
549 walk_truncated = true;
550 files.truncate(max_files);
551 break;
552 }
553 }
554 }
555
556 sort_paths_by_mtime_desc(&mut files);
557 FallbackWalkOutcome {
558 files,
559 walk_truncated,
560 entries_visited,
561 }
562}
563
564pub(crate) fn for_each_bounded_fallback_walk_file<F>(
565 filter_root: &Path,
566 search_root: &Path,
567 filters: &PathFilters,
568 project_root: &Path,
569 path_exclusion: Option<GrepPathExclusion>,
570 mut on_file: F,
571) -> bool
572where
573 F: FnMut(&PathBuf),
574{
575 for_each_bounded_fallback_walk_file_with_limits(
576 filter_root,
577 search_root,
578 filters,
579 project_root,
580 path_exclusion,
581 MAX_FALLBACK_WALK_FILES,
582 FALLBACK_WALK_BUDGET,
583 &mut on_file,
584 )
585}
586
587fn for_each_bounded_fallback_walk_file_with_limits<F>(
588 filter_root: &Path,
589 search_root: &Path,
590 filters: &PathFilters,
591 project_root: &Path,
592 path_exclusion: Option<GrepPathExclusion>,
593 max_files: usize,
594 budget: Duration,
595 on_file: &mut F,
596) -> bool
597where
598 F: FnMut(&PathBuf),
599{
600 let started = Instant::now();
601 let mut files_seen = 0usize;
602 let builder = fallback_project_walk_builder(search_root);
603
604 for entry in builder.build().filter_map(|entry| entry.ok()) {
605 if started.elapsed() >= budget {
606 return true;
607 }
608 if !entry
609 .file_type()
610 .map_or(false, |file_type| file_type.is_file())
611 {
612 continue;
613 }
614 let path = entry.into_path();
615 if path_exclusion.is_some_and(|exclude| exclude(&path, project_root)) {
616 continue;
617 }
618 if filters.matches(filter_root, &path) {
619 files_seen += 1;
620 if files_seen > max_files {
621 return true;
622 }
623 on_file(&path);
624 }
625 }
626 false
627}
628
629pub fn weakest_index_status(left: IndexStatus, right: IndexStatus) -> IndexStatus {
630 match (left, right) {
631 (IndexStatus::Disabled, _) | (_, IndexStatus::Disabled) => IndexStatus::Disabled,
632 (IndexStatus::Fallback, _) | (_, IndexStatus::Fallback) => IndexStatus::Fallback,
633 (IndexStatus::Building, _) | (_, IndexStatus::Building) => IndexStatus::Building,
634 (IndexStatus::Ready, IndexStatus::Ready) => IndexStatus::Ready,
635 }
636}
637
638#[doc(hidden)]
640pub fn fallback_grep_bench(
641 project_root: &Path,
642 search_root: &Path,
643 filter_root: &Path,
644 pattern: &CompiledPattern,
645 include: &[String],
646 exclude: &[String],
647 max_results: usize,
648) -> GrepResult {
649 fallback_grep(
650 project_root,
651 search_root,
652 filter_root,
653 pattern,
654 include,
655 exclude,
656 max_results,
657 IndexStatus::Fallback,
658 None,
659 )
660}
661
662fn fallback_grep(
663 project_root: &Path,
664 search_root: &Path,
665 filter_root: &Path,
666 pattern: &CompiledPattern,
667 include: &[String],
668 exclude: &[String],
669 max_results: usize,
670 index_status: IndexStatus,
671 path_exclusion: Option<GrepPathExclusion>,
672) -> GrepResult {
673 let filters = build_path_filters(include, exclude).unwrap_or_default();
674
675 let total_matches = AtomicUsize::new(0);
676 let files_searched = AtomicUsize::new(0);
677 let files_with_matches = AtomicUsize::new(0);
678 let truncated = AtomicBool::new(false);
679 let engine_capped = AtomicBool::new(false);
680 let stop_after = max_results.saturating_mul(2);
681 let stop_scan = Arc::new(AtomicBool::new(false));
682
683 let mut matches = Vec::new();
684 let mut batch: Vec<PathBuf> = Vec::with_capacity(256);
685
686 let flush_batch = |batch: &mut Vec<PathBuf>, matches: &mut Vec<GrepMatch>| {
687 if batch.is_empty() {
688 return;
689 }
690 let chunk = std::mem::take(batch);
691 let partial: Vec<GrepMatch> = chunk
692 .par_iter()
693 .filter_map(|file| {
694 if stop_scan.load(Ordering::Relaxed) {
695 return None;
696 }
697 let file_matches = fallback_search_file(
698 file,
699 pattern,
700 max_results,
701 stop_after,
702 &total_matches,
703 &files_searched,
704 &files_with_matches,
705 &truncated,
706 &engine_capped,
707 );
708 if truncated.load(Ordering::Relaxed)
709 && total_matches.load(Ordering::Relaxed) >= stop_after
710 {
711 stop_scan.store(true, Ordering::Relaxed);
712 }
713 (!file_matches.is_empty()).then_some(file_matches)
714 })
715 .flatten()
716 .collect();
717 matches.extend(partial);
718 };
719
720 let walk_truncated = for_each_bounded_fallback_walk_file(
721 filter_root,
722 search_root,
723 &filters,
724 project_root,
725 path_exclusion,
726 |path| {
727 if stop_scan.load(Ordering::Relaxed) {
728 return;
729 }
730 batch.push(path.clone());
731 if batch.len() >= 256 {
732 flush_batch(&mut batch, &mut matches);
733 }
734 },
735 );
736 flush_batch(&mut batch, &mut matches);
737
738 sort_grep_matches_by_mtime_desc(&mut matches, project_root);
739
740 GrepResult {
741 total_matches: total_matches.load(Ordering::Relaxed),
742 matches,
743 files_searched: files_searched.load(Ordering::Relaxed),
744 files_with_matches: files_with_matches.load(Ordering::Relaxed),
745 index_status,
746 truncated: truncated.load(Ordering::Relaxed),
747 fully_degraded: true,
748 engine_capped: engine_capped.load(Ordering::Relaxed),
749 walk_truncated,
750 }
751}
752
753fn fallback_search_file(
754 file: &PathBuf,
755 pattern: &CompiledPattern,
756 max_results: usize,
757 stop_after: usize,
758 total_matches: &AtomicUsize,
759 files_searched: &AtomicUsize,
760 files_with_matches: &AtomicUsize,
761 truncated: &AtomicBool,
762 engine_capped: &AtomicBool,
763) -> Vec<GrepMatch> {
764 if should_stop_fallback_search(truncated, total_matches, stop_after) {
765 engine_capped.store(true, Ordering::Relaxed);
766 return Vec::new();
767 }
768
769 let Some(content) = read_searchable_text(file) else {
770 return Vec::new();
771 };
772 files_searched.fetch_add(1, Ordering::Relaxed);
773
774 let line_starts = line_starts(&content);
775 let mut seen_lines = HashSet::new();
776 let mut matched_this_file = false;
777 let mut matches = Vec::new();
778
779 match pattern {
780 CompiledPattern::Literal(literal) => search_literal_in_text(
781 file,
782 &content,
783 &line_starts,
784 literal,
785 max_results,
786 stop_after,
787 total_matches,
788 &mut seen_lines,
789 truncated,
790 engine_capped,
791 &mut matched_this_file,
792 &mut matches,
793 ),
794 CompiledPattern::Regex { compiled, .. } => {
795 for matched in compiled.find_iter(content.as_bytes()) {
796 if should_stop_fallback_search(truncated, total_matches, stop_after) {
797 engine_capped.store(true, Ordering::Relaxed);
798 break;
799 }
800
801 let (line, column, line_text) =
802 line_details(&content, &line_starts, matched.start());
803 if !seen_lines.insert(line) {
804 continue;
805 }
806
807 matched_this_file = true;
808 let match_number = total_matches.fetch_add(1, Ordering::Relaxed) + 1;
809 if match_number > max_results {
810 truncated.store(true, Ordering::Relaxed);
811 break;
812 }
813
814 matches.push(GrepMatch {
815 file: file.clone(),
816 line,
817 column,
818 line_text,
819 match_text: String::from_utf8_lossy(matched.as_bytes()).into_owned(),
820 });
821 }
822 }
823 }
824
825 if matched_this_file {
826 files_with_matches.fetch_add(1, Ordering::Relaxed);
827 }
828
829 matches
830}
831
832fn search_literal_in_text(
833 file: &Path,
834 content: &str,
835 line_starts: &[usize],
836 literal: &LiteralSearch,
837 max_results: usize,
838 stop_after: usize,
839 total_matches: &AtomicUsize,
840 seen_lines: &mut HashSet<u32>,
841 truncated: &AtomicBool,
842 engine_capped: &AtomicBool,
843 matched_this_file: &mut bool,
844 matches: &mut Vec<GrepMatch>,
845) {
846 let content_bytes = content.as_bytes();
847 let search_content;
848 let haystack = if literal.case_insensitive_ascii {
849 search_content = content_bytes.to_ascii_lowercase();
850 search_content.as_slice()
851 } else {
852 content_bytes
853 };
854 let finder = memchr::memmem::Finder::new(&literal.needle);
855 let mut start = 0usize;
856
857 while let Some(position) = finder.find(&haystack[start..]) {
858 if should_stop_fallback_search(truncated, total_matches, stop_after) {
859 engine_capped.store(true, Ordering::Relaxed);
860 break;
861 }
862
863 let offset = start + position;
864 start = offset + 1;
865 let (line, column, line_text) = line_details(content, line_starts, offset);
866 if !seen_lines.insert(line) {
867 continue;
868 }
869
870 *matched_this_file = true;
871 let match_number = total_matches.fetch_add(1, Ordering::Relaxed) + 1;
872 if match_number > max_results {
873 truncated.store(true, Ordering::Relaxed);
874 break;
875 }
876
877 let end = offset + literal.needle.len();
878 matches.push(GrepMatch {
879 file: file.to_path_buf(),
880 line,
881 column,
882 line_text,
883 match_text: String::from_utf8_lossy(&content_bytes[offset..end]).into_owned(),
884 });
885 }
886}
887
888fn should_stop_fallback_search(
889 truncated: &AtomicBool,
890 total_matches: &AtomicUsize,
891 stop_after: usize,
892) -> bool {
893 truncated.load(Ordering::Relaxed) && total_matches.load(Ordering::Relaxed) >= stop_after
894}
895
896pub(crate) fn ripgrep_glob(
897 search_root: &Path,
898 pattern: &str,
899 max_results: usize,
900) -> Option<FallbackWalkOutcome> {
901 let filters = build_path_filters(&[pattern.to_string()], &[]).ok()?;
902 let mut outcome = bounded_fallback_walk_files(search_root, search_root, &filters);
903 outcome.files.truncate(max_results);
904 Some(outcome)
905}
906
907fn current_index_status(ctx: &AppContext) -> IndexStatus {
908 let index_ready = {
909 let search_index = ctx
910 .search_index()
911 .read()
912 .unwrap_or_else(std::sync::PoisonError::into_inner);
913 search_index.as_ref().is_some_and(|index| index.ready)
914 };
915 if index_ready {
916 return IndexStatus::Ready;
917 }
918
919 let build_in_progress = {
920 let search_index_rx = ctx
921 .search_index_rx()
922 .read()
923 .unwrap_or_else(std::sync::PoisonError::into_inner);
924 search_index_rx.is_some()
925 };
926 let has_index = {
927 let search_index = ctx
928 .search_index()
929 .read()
930 .unwrap_or_else(std::sync::PoisonError::into_inner);
931 search_index.is_some()
932 };
933 if build_in_progress || has_index {
934 IndexStatus::Building
935 } else {
936 IndexStatus::Fallback
937 }
938}
939
940pub fn line_starts(content: &str) -> Vec<usize> {
941 let mut starts = vec![0usize];
942 for (index, byte) in content.bytes().enumerate() {
943 if byte == b'\n' {
944 starts.push(index + 1);
945 }
946 }
947 starts
948}
949
950pub fn floor_char_boundary_str(content: &str, mut index: usize) -> usize {
952 index = index.min(content.len());
953 while index > 0 && !content.is_char_boundary(index) {
954 index -= 1;
955 }
956 index
957}
958
959pub fn truncate_at_char_boundary(content: &str, max_bytes: usize) -> &str {
961 let end = floor_char_boundary_str(content, max_bytes);
962 &content[..end]
963}
964
965pub fn line_details(content: &str, line_starts: &[usize], offset: usize) -> (u32, u32, String) {
966 let offset = floor_char_boundary_str(content, offset);
967 let line_index = match line_starts.binary_search(&offset) {
968 Ok(index) => index,
969 Err(index) => index.saturating_sub(1),
970 };
971 let line_start = line_starts.get(line_index).copied().unwrap_or(0);
972 let line_end = content[line_start..]
973 .find('\n')
974 .map(|length| line_start + length)
975 .unwrap_or(content.len());
976 let line_text = content[line_start..line_end]
977 .trim_end_matches('\r')
978 .to_string();
979 let column = content[line_start..offset].chars().count() as u32 + 1;
980 (line_index as u32 + 1, column, line_text)
981}
982
983#[cfg(test)]
984mod tests {
985 use super::*;
986
987 fn grep_match(file: &Path, line: u32, column: u32) -> GrepMatch {
988 GrepMatch {
989 file: file.to_path_buf(),
990 line,
991 column,
992 line_text: "needle".to_string(),
993 match_text: "needle".to_string(),
994 }
995 }
996
997 fn result(matches: Vec<GrepMatch>, truncated: bool, status: IndexStatus) -> GrepResult {
998 GrepResult {
999 total_matches: matches.len(),
1000 files_searched: matches.len(),
1001 files_with_matches: matches.len(),
1002 matches,
1003 index_status: status,
1004 truncated,
1005 fully_degraded: false,
1006 engine_capped: false,
1007 walk_truncated: false,
1008 }
1009 }
1010
1011 #[test]
1012 fn optional_path_exclusion_controls_visible_totals_without_affecting_default_grep() {
1013 fn excludes_tests(path: &Path, root: &Path) -> bool {
1014 path.strip_prefix(root)
1015 .is_ok_and(|relative| relative.starts_with("tests"))
1016 }
1017
1018 let project = tempfile::tempdir().expect("project");
1019 let test_file = project.path().join("tests/case.rs");
1020 let source_file = project.path().join("src/lib.rs");
1021 std::fs::create_dir_all(test_file.parent().expect("test parent")).expect("test dir");
1022 std::fs::create_dir_all(source_file.parent().expect("source parent")).expect("source dir");
1023 std::fs::write(&test_file, "const NEEDLE: &str = \"needle\";\n").expect("test file");
1024 std::fs::write(&source_file, "pub fn needle() {}\n").expect("source file");
1025 let pattern = match crate::pattern_compile::compile(
1026 "needle",
1027 crate::pattern_compile::CompileOpts {
1028 literal: true,
1029 ..crate::pattern_compile::CompileOpts::default()
1030 },
1031 ) {
1032 crate::pattern_compile::CompileResult::Ok(pattern) => pattern,
1033 other => panic!("compile literal: {other:?}"),
1034 };
1035
1036 let unfiltered = fallback_grep(
1037 project.path(),
1038 project.path(),
1039 project.path(),
1040 &pattern,
1041 &[],
1042 &[],
1043 10,
1044 IndexStatus::Fallback,
1045 None,
1046 );
1047 assert_eq!(unfiltered.total_matches, 2);
1048 assert_eq!(unfiltered.matches.len(), 2);
1049
1050 let visible = fallback_grep(
1051 project.path(),
1052 project.path(),
1053 project.path(),
1054 &pattern,
1055 &[],
1056 &[],
1057 10,
1058 IndexStatus::Fallback,
1059 Some(excludes_tests),
1060 );
1061 assert_eq!(visible.total_matches, 1);
1062 assert_eq!(visible.matches.len(), 1);
1063 assert_eq!(visible.files_searched, 1);
1064 assert_eq!(visible.files_with_matches, 1);
1065 assert_eq!(visible.matches[0].file, source_file);
1066 assert!(!visible.truncated);
1067 assert!(!visible.engine_capped);
1068 }
1069
1070 #[test]
1071 fn single_root_uses_requested_max() {
1072 let scope = GrepScope {
1073 roots: vec![ResolvedRoot {
1074 search_root: PathBuf::from("/project"),
1075 filter_root: PathBuf::from("/project"),
1076 use_index: true,
1077 is_external: false,
1078 }],
1079 multi_root: false,
1080 per_root_max: 10,
1081 };
1082 assert!(!scope.multi_root);
1083 assert_eq!(scope.per_root_max, 10);
1084 }
1085
1086 #[test]
1087 fn multi_root_uses_double_per_root_max() {
1088 let project = tempfile::tempdir().expect("project");
1089 let ctx = AppContext::new(
1090 Box::new(crate::parser::TreeSitterProvider::new()),
1091 crate::config::Config {
1092 project_root: Some(project.path().to_path_buf()),
1093 ..crate::config::Config::default()
1094 },
1095 );
1096 let left = project.path().join("left");
1097 let right = project.path().join("right");
1098 std::fs::create_dir_all(&left).expect("left");
1099 std::fs::create_dir_all(&right).expect("right");
1100 let paths = serde_json::json!([left.display().to_string(), right.display().to_string()]);
1101
1102 let scope = resolve_grep_scope(&ctx, Some(&paths), 10, "test").expect("scope");
1103
1104 assert!(scope.multi_root);
1105 assert_eq!(scope.per_root_max, 20);
1106 }
1107
1108 #[test]
1109 fn bounded_fallback_walk_truncates_at_file_cap() {
1110 let dir = tempfile::tempdir().expect("tempdir");
1111 let root = dir.path();
1112 for i in 0..25 {
1113 let path = root.join(format!("file_{i:03}.txt"));
1114 std::fs::write(path, "needle\n").expect("write");
1115 }
1116 let filters = build_path_filters(&["**/*.txt".to_string()], &[]).expect("filters");
1117 let outcome = bounded_fallback_walk_files_with_limits(
1118 root,
1119 root,
1120 &filters,
1121 20,
1122 Duration::from_secs(60),
1123 );
1124 assert!(outcome.walk_truncated);
1125 assert_eq!(outcome.files.len(), 20);
1126 }
1127
1128 #[test]
1129 fn bounded_fallback_walk_small_tree_not_truncated() {
1130 let dir = tempfile::tempdir().expect("tempdir");
1131 let root = dir.path();
1132 std::fs::write(root.join("a.txt"), "x\n").expect("write");
1133 std::fs::write(root.join("b.txt"), "x\n").expect("write");
1134 let filters = build_path_filters(&["**/*.txt".to_string()], &[]).expect("filters");
1135 let outcome = bounded_fallback_walk_files(root, root, &filters);
1136 assert!(!outcome.walk_truncated);
1137 assert_eq!(outcome.files.len(), 2);
1138 }
1139
1140 #[test]
1141 fn filter_root_is_project_for_in_project_and_search_root_for_external_unindexed() {
1142 let project = PathBuf::from("/project");
1143 let in_project = compute_filter_root(&project, Path::new("/project/src"), true, false);
1144 let external = compute_filter_root(&project, Path::new("/tmp/external"), false, true);
1145 assert_eq!(in_project, project);
1146 assert_eq!(external, PathBuf::from("/tmp/external"));
1147 }
1148
1149 #[test]
1150 fn weakest_status_orders_disabled_fallback_building_ready() {
1151 assert_eq!(
1152 weakest_index_status(IndexStatus::Ready, IndexStatus::Building),
1153 IndexStatus::Building
1154 );
1155 assert_eq!(
1156 weakest_index_status(IndexStatus::Building, IndexStatus::Fallback),
1157 IndexStatus::Fallback
1158 );
1159 assert_eq!(
1160 weakest_index_status(IndexStatus::Fallback, IndexStatus::Disabled),
1161 IndexStatus::Disabled
1162 );
1163 }
1164
1165 #[test]
1166 fn merge_dedupes_by_canonical_file_line_column() {
1167 let temp = tempfile::tempdir().expect("temp");
1168 let file = temp.path().join("file.rs");
1169 std::fs::write(&file, "needle").expect("write");
1170 let symlink = temp.path().join("link.rs");
1171 #[cfg(unix)]
1172 std::os::unix::fs::symlink(&file, &symlink).expect("symlink");
1173 #[cfg(windows)]
1174 std::os::windows::fs::symlink_file(&file, &symlink).expect("symlink");
1175
1176 let merged = merge_grep_results(
1177 vec![
1178 result(vec![grep_match(&file, 1, 1)], false, IndexStatus::Ready),
1179 result(vec![grep_match(&symlink, 1, 1)], false, IndexStatus::Ready),
1180 ],
1181 temp.path(),
1182 10,
1183 );
1184
1185 assert_eq!(merged.matches.len(), 1);
1186 }
1187
1188 #[test]
1189 fn merge_truncated_when_child_truncated_or_pre_merge_exceeds_max() {
1190 let root = Path::new("/project");
1191 let child = merge_grep_results(
1192 vec![result(
1193 vec![grep_match(Path::new("/project/a.rs"), 1, 1)],
1194 true,
1195 IndexStatus::Ready,
1196 )],
1197 root,
1198 10,
1199 );
1200 assert!(child.truncated);
1201
1202 let many = merge_grep_results(
1203 vec![
1204 result(
1205 vec![grep_match(Path::new("/project/a.rs"), 1, 1)],
1206 false,
1207 IndexStatus::Ready,
1208 ),
1209 result(
1210 vec![grep_match(Path::new("/project/b.rs"), 1, 1)],
1211 false,
1212 IndexStatus::Ready,
1213 ),
1214 ],
1215 root,
1216 1,
1217 );
1218 assert!(many.truncated);
1219 }
1220
1221 #[test]
1222 fn line_details_floors_offset_inside_multibyte_char() {
1223 let content = "before—after";
1224 let starts = line_starts(content);
1225 let dash_byte = content.find('—').expect("em dash");
1226 let mid_byte = dash_byte + 1;
1227 assert!(!content.is_char_boundary(mid_byte));
1228 let (line, column, line_text) = line_details(content, &starts, mid_byte);
1229 assert_eq!(line, 1);
1230 assert_eq!(column, content[..dash_byte].chars().count() as u32 + 1);
1231 assert!(line_text.contains('—'));
1232 }
1233
1234 #[test]
1235 fn line_details_clamps_offset_past_end() {
1236 let content = "short";
1237 let starts = line_starts(content);
1238 let (line, column, _) = line_details(content, &starts, content.len() + 100);
1239 assert_eq!(line, 1);
1240 assert_eq!(column, 6);
1241 }
1242
1243 #[test]
1244 fn truncate_at_char_boundary_floors_mid_multibyte_at_byte_cap() {
1245 let mut prefix = "a".repeat(38);
1246 prefix.push('—');
1247 prefix.push_str("tail");
1248 assert_eq!(prefix.len(), 45);
1249 assert!(!prefix.is_char_boundary(40));
1250 let truncated = truncate_at_char_boundary(&prefix, 40);
1251 assert!(truncated.is_char_boundary(truncated.len()));
1252 assert!(truncated.ends_with('a'));
1253 assert!(!truncated.contains('—'));
1254 }
1255
1256 #[test]
1257 fn regex_byte_match_start_mid_char_does_not_panic_in_line_details() {
1258 use crate::pattern_compile::{CompileOpts, CompileResult};
1259
1260 let content = "xy—zz";
1261 let starts = line_starts(content);
1262 let compiled = match crate::pattern_compile::compile(
1263 ".",
1264 CompileOpts {
1265 multi_line: false,
1266 ..CompileOpts::default()
1267 },
1268 ) {
1269 CompileResult::Ok(compiled) => compiled,
1270 other => panic!("expected compiled pattern, got {other:?}"),
1271 };
1272 let crate::pattern_compile::CompiledPattern::Regex { compiled, .. } = compiled else {
1273 panic!("expected regex pattern");
1274 };
1275 for matched in compiled.find_iter(content.as_bytes()) {
1276 let _ = line_details(content, &starts, matched.start());
1277 }
1278 }
1279}