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