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