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, try_read_with_budget, GrepMatch,
20    GrepPathExclusion, GrepQueryPhaseTimings, GrepResult, IndexStatus, PathFilters,
21    INTERACTIVE_ARTIFACT_READ_BUDGET,
22};
23
24/// Maximum files enumerated during grep/glob index-unavailable fallback walks.
25pub(crate) const MAX_FALLBACK_WALK_FILES: usize = 50_000;
26/// Wall-clock budget for grep/glob index-unavailable fallback walks on the dispatch thread.
27pub(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        // An explicitly-named existing file is always in scope (it's searched
157        // directly even if gitignored / .aftignored), so don't report it as
158        // "no files matched scope".
159        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    // Explicit single-file scope: search the named file directly, bypassing the
281    // trigram index and the gitignore/.aftignore-aware walk. Matches ripgrep,
282    // where naming a file explicitly searches it even when it is gitignored,
283    // .aftignored, or not yet indexed. Binary + UTF-8 guards still apply.
284    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            &params.include,
325            &params.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            &params.include,
360            &params.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
386/// Grep a single explicitly-named file directly, bypassing the trigram index
387/// and the gitignore/.aftignore-aware walk. Used when the caller's `path`
388/// resolves to one existing file — ripgrep semantics: an explicitly-named file
389/// is searched even when it is gitignored, `.aftignore`d, or not yet indexed.
390/// Binary detection and UTF-8 guards still apply (via `read_searchable_text`
391/// inside `fallback_search_file`).
392fn 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
512/// Bounded project walk used when the trigram index is unavailable (grep/glob fallback).
513pub(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/// Hidden entry for `search_startup_bench` timing (fallback grep path).
646#[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
944/// Floor a byte index to the nearest valid `str` char boundary (never panics).
945pub 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
953/// Prefix of `content` with at most `max_bytes` UTF-8 bytes, truncated on a char boundary.
954pub 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}