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, GrepQueryPhaseTimings,
20    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}
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        // An explicitly-named existing file is always in scope (it's searched
155        // directly even if gitignored / .aftignored), so don't report it as
156        // "no files matched scope".
157        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    // Explicit single-file scope: search the named file directly, bypassing the
279    // trigram index and the gitignore/.aftignore-aware walk. Matches ripgrep,
280    // where naming a file explicitly searches it even when it is gitignored,
281    // .aftignored, or not yet indexed. Binary + UTF-8 guards still apply.
282    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            &params.include,
316            &params.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            &params.include,
346            &params.exclude,
347            max_results,
348            index_status,
349        ),
350        GrepExecutionPhaseTimings {
351            snapshot_acquire,
352            ..GrepExecutionPhaseTimings::default()
353        },
354    )
355}
356
357/// Grep a single explicitly-named file directly, bypassing the trigram index
358/// and the gitignore/.aftignore-aware walk. Used when the caller's `path`
359/// resolves to one existing file — ripgrep semantics: an explicitly-named file
360/// is searched even when it is gitignored, `.aftignore`d, or not yet indexed.
361/// Binary detection and UTF-8 guards still apply (via `read_searchable_text`
362/// inside `fallback_search_file`).
363fn 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
483/// Bounded project walk used when the trigram index is unavailable (grep/glob fallback).
484pub(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/// Hidden entry for `search_startup_bench` timing (fallback grep path).
608#[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
911/// Floor a byte index to the nearest valid `str` char boundary (never panics).
912pub 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
920/// Prefix of `content` with at most `max_bytes` UTF-8 bytes, truncated on a char boundary.
921pub 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}