Skip to main content

aft/
grep_executor.rs

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
23/// Maximum files enumerated during grep/glob index-unavailable fallback walks.
24pub(crate) const MAX_FALLBACK_WALK_FILES: usize = 50_000;
25/// Wall-clock budget for grep/glob index-unavailable fallback walks on the dispatch thread.
26pub(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        // An explicitly-named existing file is always in scope (it's searched
156        // directly even if gitignored / .aftignored), so don't report it as
157        // "no files matched scope".
158        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    // Explicit single-file scope: search the named file directly, bypassing the
280    // trigram index and the gitignore/.aftignore-aware walk. Matches ripgrep,
281    // where naming a file explicitly searches it even when it is gitignored,
282    // .aftignored, or not yet indexed. Binary + UTF-8 guards still apply.
283    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            &params.include,
322            &params.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            &params.include,
353            &params.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
379/// Grep a single explicitly-named file directly, bypassing the trigram index
380/// and the gitignore/.aftignore-aware walk. Used when the caller's `path`
381/// resolves to one existing file — ripgrep semantics: an explicitly-named file
382/// is searched even when it is gitignored, `.aftignore`d, or not yet indexed.
383/// Binary detection and UTF-8 guards still apply (via `read_searchable_text`
384/// inside `fallback_search_file`).
385fn 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
505/// Bounded project walk used when the trigram index is unavailable (grep/glob fallback).
506pub(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/// Hidden entry for `search_startup_bench` timing (fallback grep path).
639#[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
950/// Floor a byte index to the nearest valid `str` char boundary (never panics).
951pub 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
959/// Prefix of `content` with at most `max_bytes` UTF-8 bytes, truncated on a char boundary.
960pub 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}