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, decompose_grep_pattern, has_any_project_file_from, read_searchable_text,
19    resolve_search_scope, sort_grep_matches_by_mtime_desc, sort_paths_by_mtime_desc,
20    try_read_with_budget, GrepMatch, GrepPathExclusion, GrepQueryPhaseTimings, GrepResult,
21    IndexStatus, PathFilters, RegexQuery, 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    /// Regex decomposition is paid once per grep request, not once per root.
48    pub query_decomposition: Duration,
49    pub query: GrepQueryPhaseTimings,
50    pub indexed_scope_has_files: Option<bool>,
51}
52
53impl GrepExecutionPhaseTimings {
54    fn add(&mut self, other: Self) {
55        self.snapshot_acquire += other.snapshot_acquire;
56        self.query.trigram_lookup += other.query.trigram_lookup;
57        self.query.pread_verify += other.query.pread_verify;
58        self.query.post_filter += other.query.post_filter;
59        self.query.candidate_count += other.query.candidate_count;
60        self.query.bytes_verified += other.query.bytes_verified;
61        self.indexed_scope_has_files =
62            match (self.indexed_scope_has_files, other.indexed_scope_has_files) {
63                (Some(left), Some(right)) => Some(left || right),
64                _ => None,
65            };
66    }
67}
68
69#[derive(Clone, Debug)]
70pub struct GrepScope {
71    pub roots: Vec<ResolvedRoot>,
72    pub multi_root: bool,
73    pub per_root_max: usize,
74}
75
76#[derive(Clone, Debug)]
77pub struct ResolvedRoot {
78    pub search_root: PathBuf,
79    pub filter_root: PathBuf,
80    pub use_index: bool,
81    pub is_external: bool,
82}
83
84pub fn project_root(ctx: &AppContext) -> PathBuf {
85    let project_root = ctx
86        .config()
87        .project_root
88        .clone()
89        .unwrap_or_else(|| env::current_dir().unwrap_or_default());
90    std::fs::canonicalize(&project_root).unwrap_or(project_root)
91}
92
93pub fn resolve_grep_scope(
94    ctx: &AppContext,
95    paths: Option<&serde_json::Value>,
96    max_results: usize,
97    req_id: &str,
98) -> Result<GrepScope, Response> {
99    let project_root = project_root(ctx);
100    let search_roots = resolve_roots(ctx, paths, &project_root, req_id)?;
101
102    if let Some(missing_root) = search_roots.iter().find(|root| !root.exists()) {
103        return Err(Response::error(
104            req_id,
105            "path_not_found",
106            format!(
107                "grep: search path does not exist: {}",
108                missing_root.display()
109            ),
110        ));
111    }
112
113    let roots = search_roots
114        .into_iter()
115        .map(|search_root| {
116            let scope = resolve_search_scope(&project_root, Some(&search_root.to_string_lossy()));
117            let is_external = !scope.use_index;
118            let filter_root =
119                compute_filter_root(&project_root, &scope.root, scope.use_index, is_external);
120            ResolvedRoot {
121                search_root: scope.root,
122                filter_root,
123                use_index: scope.use_index,
124                is_external,
125            }
126        })
127        .collect::<Vec<_>>();
128
129    let multi_root = roots.len() > 1;
130    let per_root_max = if multi_root {
131        max_results.saturating_mul(2).max(max_results)
132    } else {
133        max_results
134    };
135
136    Ok(GrepScope {
137        roots,
138        multi_root,
139        per_root_max,
140    })
141}
142
143pub fn compute_filter_root(
144    project_root: &Path,
145    search_root: &Path,
146    use_index: bool,
147    is_external: bool,
148) -> PathBuf {
149    if is_external && !use_index {
150        search_root.to_path_buf()
151    } else {
152        project_root.to_path_buf()
153    }
154}
155
156pub fn scope_has_files(project_root: &Path, scope: &GrepScope) -> bool {
157    scope.roots.iter().any(|root| {
158        // An explicitly-named existing file is always in scope (it's searched
159        // directly even if gitignored / .aftignored), so don't report it as
160        // "no files matched scope".
161        if root.search_root.is_file() {
162            return true;
163        }
164        let catch_all =
165            build_path_filters(&["**/*".to_string()], &[]).expect("valid catch-all glob");
166        has_any_project_file_from(&root.filter_root, &root.search_root, &catch_all)
167            || has_any_project_file_from(project_root, &root.search_root, &catch_all)
168    })
169}
170
171pub fn execute(
172    ctx: &AppContext,
173    pattern: &CompiledPattern,
174    scope: &GrepScope,
175    params: &GrepParams,
176) -> GrepResult {
177    execute_profiled(ctx, pattern, scope, params).0
178}
179
180pub(crate) fn execute_profiled(
181    ctx: &AppContext,
182    pattern: &CompiledPattern,
183    scope: &GrepScope,
184    params: &GrepParams,
185) -> (GrepResult, GrepExecutionPhaseTimings) {
186    let filters = build_path_filters(&params.include, &params.exclude).unwrap_or_default();
187    execute_profiled_with_filters(ctx, pattern, scope, params, &filters)
188}
189
190pub(crate) fn execute_profiled_with_filters(
191    ctx: &AppContext,
192    pattern: &CompiledPattern,
193    scope: &GrepScope,
194    params: &GrepParams,
195    filters: &PathFilters,
196) -> (GrepResult, GrepExecutionPhaseTimings) {
197    let project_root = project_root(ctx);
198    let query_started = Instant::now();
199    let query = decompose_grep_pattern(pattern);
200    let query_decomposition = query_started.elapsed();
201    if scope.roots.len() == 1 {
202        let (result, mut phases) = execute_root_profiled(
203            ctx,
204            pattern,
205            &query,
206            &scope.roots[0],
207            params,
208            filters,
209            params.max_results,
210            &project_root,
211        );
212        phases.query_decomposition = query_decomposition;
213        return (result, phases);
214    }
215
216    let mut results = Vec::new();
217    let mut phases: Option<GrepExecutionPhaseTimings> = None;
218    for root in &scope.roots {
219        let (result, root_phases) = execute_root_profiled(
220            ctx,
221            pattern,
222            &query,
223            root,
224            params,
225            filters,
226            scope.per_root_max,
227            &project_root,
228        );
229        results.push(result);
230        if let Some(phases) = phases.as_mut() {
231            phases.add(root_phases);
232        } else {
233            phases = Some(root_phases);
234        }
235    }
236    let mut phases = phases.unwrap_or_default();
237    phases.query_decomposition = query_decomposition;
238    (
239        merge_grep_results(results, &project_root, params.max_results),
240        phases,
241    )
242}
243
244fn resolve_roots(
245    ctx: &AppContext,
246    paths: Option<&serde_json::Value>,
247    project_root: &Path,
248    req_id: &str,
249) -> Result<Vec<PathBuf>, Response> {
250    let Some(paths) = paths else {
251        return Ok(vec![resolve_search_scope(project_root, None).root]);
252    };
253    if paths.is_null() {
254        return Ok(vec![resolve_search_scope(project_root, None).root]);
255    }
256    if let Some(path) = paths.as_str() {
257        return match resolve_path_or_multi(
258            path,
259            project_root,
260            |candidate| ctx.validate_path(req_id, candidate),
261            req_id,
262        )? {
263            SearchPathResolution::Single(root) => Ok(vec![root]),
264            SearchPathResolution::Multi(roots) => Ok(roots),
265        };
266    }
267    if let Some(items) = paths.as_array() {
268        let mut roots = Vec::with_capacity(items.len());
269        for item in items {
270            let Some(path) = item.as_str() else {
271                return Err(Response::error(
272                    req_id,
273                    "invalid_request",
274                    "grep: path array entries must be strings",
275                ));
276            };
277            let validated = ctx.validate_path(req_id, Path::new(path))?;
278            let raw = validated.to_string_lossy();
279            roots.push(resolve_search_scope(project_root, Some(raw.as_ref())).root);
280        }
281        let roots = dedupe_nested_paths(roots);
282        if roots.is_empty() {
283            Ok(vec![resolve_search_scope(project_root, None).root])
284        } else {
285            Ok(roots)
286        }
287    } else {
288        Err(Response::error(
289            req_id,
290            "invalid_request",
291            "grep: path must be a string, array of strings, or null",
292        ))
293    }
294}
295
296fn execute_root_profiled(
297    ctx: &AppContext,
298    pattern: &CompiledPattern,
299    query: &RegexQuery,
300    root: &ResolvedRoot,
301    params: &GrepParams,
302    filters: &PathFilters,
303    max_results: usize,
304    project_root: &Path,
305) -> (GrepResult, GrepExecutionPhaseTimings) {
306    // Explicit single-file scope: search the named file directly, bypassing the
307    // trigram index and the gitignore/.aftignore-aware walk. Matches ripgrep,
308    // where naming a file explicitly searches it even when it is gitignored,
309    // .aftignored, or not yet indexed. Binary + UTF-8 guards still apply.
310    if root.search_root.is_file() {
311        if root.use_index {
312            crate::commands::configure::trigger_search_index_reload_if_evicted(ctx);
313        }
314        let index_status = if root.use_index {
315            current_index_status(ctx)
316        } else {
317            IndexStatus::Fallback
318        };
319        let result = if params
320            .path_exclusion
321            .is_some_and(|exclude| exclude(&root.search_root, project_root))
322        {
323            empty_grep_result(index_status, false)
324        } else {
325            grep_explicit_file(&root.search_root, pattern, max_results, index_status)
326        };
327        return (result, GrepExecutionPhaseTimings::default());
328    }
329
330    let snapshot_started = Instant::now();
331    let mut snapshot_timed_out = false;
332    let indexed_snapshot =
333        match try_read_with_budget(ctx.search_index(), INTERACTIVE_ARTIFACT_READ_BUDGET) {
334            Some(search_index) => match search_index.as_ref() {
335                Some(index) if index.ready && root.use_index => Some(index.snapshot()),
336                _ => None,
337            },
338            None => {
339                snapshot_timed_out = true;
340                None
341            }
342        };
343    let snapshot_acquire = snapshot_started.elapsed();
344    if let Some(snapshot) = indexed_snapshot {
345        let scope_started = Instant::now();
346        let indexed_scope_has_files = snapshot.has_file_in_scope(&root.search_root);
347        let scope_elapsed = scope_started.elapsed();
348        let (result, mut query_timings) = snapshot.search_grep_profiled_with_filters_and_query(
349            pattern,
350            query,
351            filters,
352            &root.search_root,
353            max_results,
354            params.path_exclusion,
355        );
356        query_timings.post_filter += scope_elapsed;
357        return (
358            result,
359            GrepExecutionPhaseTimings {
360                snapshot_acquire,
361                query_decomposition: Duration::ZERO,
362                query: query_timings,
363                indexed_scope_has_files: Some(indexed_scope_has_files),
364            },
365        );
366    }
367
368    if root.use_index {
369        crate::commands::configure::trigger_search_index_reload_if_evicted(ctx);
370    }
371    let index_status = if root.use_index {
372        if snapshot_timed_out {
373            IndexStatus::Fallback
374        } else {
375            current_index_status(ctx)
376        }
377    } else {
378        IndexStatus::Fallback
379    };
380    (
381        fallback_grep(
382            project_root,
383            &root.search_root,
384            &root.filter_root,
385            pattern,
386            filters,
387            max_results,
388            index_status,
389            params.path_exclusion,
390        ),
391        GrepExecutionPhaseTimings {
392            snapshot_acquire,
393            ..GrepExecutionPhaseTimings::default()
394        },
395    )
396}
397
398fn empty_grep_result(index_status: IndexStatus, fully_degraded: bool) -> GrepResult {
399    GrepResult {
400        matches: Vec::new(),
401        total_matches: 0,
402        files_searched: 0,
403        files_with_matches: 0,
404        index_status,
405        truncated: false,
406        fully_degraded,
407        engine_capped: false,
408        walk_truncated: false,
409    }
410}
411
412/// Grep a single explicitly-named file directly, bypassing the trigram index
413/// and the gitignore/.aftignore-aware walk. Used when the caller's `path`
414/// resolves to one existing file — ripgrep semantics: an explicitly-named file
415/// is searched even when it is gitignored, `.aftignore`d, or not yet indexed.
416/// Binary detection and UTF-8 guards still apply (via `read_searchable_text`
417/// inside `fallback_search_file`).
418fn grep_explicit_file(
419    file: &Path,
420    pattern: &CompiledPattern,
421    max_results: usize,
422    index_status: IndexStatus,
423) -> GrepResult {
424    let total_matches = AtomicUsize::new(0);
425    let files_searched = AtomicUsize::new(0);
426    let files_with_matches = AtomicUsize::new(0);
427    let truncated = AtomicBool::new(false);
428    let engine_capped = AtomicBool::new(false);
429    let stop_after = max_results.saturating_mul(2);
430    let job_cancellation = crate::executor::current_job_cancellation();
431
432    let matches = fallback_search_file(
433        &file.to_path_buf(),
434        pattern,
435        max_results,
436        stop_after,
437        &total_matches,
438        &files_searched,
439        &files_with_matches,
440        &truncated,
441        &engine_capped,
442        job_cancellation.as_ref(),
443        None,
444    );
445
446    GrepResult {
447        total_matches: total_matches.load(Ordering::Relaxed),
448        matches,
449        files_searched: files_searched.load(Ordering::Relaxed),
450        files_with_matches: files_with_matches.load(Ordering::Relaxed),
451        index_status,
452        truncated: truncated.load(Ordering::Relaxed),
453        fully_degraded: false,
454        engine_capped: engine_capped.load(Ordering::Relaxed),
455        walk_truncated: false,
456    }
457}
458
459pub fn merge_grep_results(
460    results: Vec<GrepResult>,
461    project_root: &Path,
462    max_results: usize,
463) -> GrepResult {
464    let mut matches = Vec::new();
465    let mut total_matches = 0usize;
466    let mut files_searched = 0usize;
467    let mut files_with_matches = 0usize;
468    let mut index_status = IndexStatus::Ready;
469    let mut any_child_truncated = false;
470    let mut fully_degraded = false;
471    let mut engine_capped = false;
472    let mut walk_truncated = false;
473    let mut seen_match_keys = HashSet::new();
474
475    for result in results {
476        total_matches += result.total_matches;
477        files_searched += result.files_searched;
478        files_with_matches += result.files_with_matches;
479        index_status = weakest_index_status(index_status, result.index_status);
480        any_child_truncated |= result.truncated;
481        fully_degraded |= result.fully_degraded;
482        engine_capped |= result.engine_capped;
483        walk_truncated |= result.walk_truncated;
484
485        for grep_match in result.matches {
486            let file_key = canonical_key(&grep_match.file);
487            let match_key = (file_key, grep_match.line, grep_match.column);
488            if seen_match_keys.insert(match_key) {
489                matches.push(grep_match);
490            }
491        }
492    }
493
494    sort_grep_matches_by_mtime_desc(&mut matches, project_root);
495    if matches.len() > max_results {
496        matches.truncate(max_results);
497    }
498
499    GrepResult {
500        matches,
501        total_matches,
502        files_searched,
503        files_with_matches,
504        index_status,
505        truncated: any_child_truncated || total_matches > max_results,
506        fully_degraded,
507        engine_capped,
508        walk_truncated,
509    }
510}
511
512fn fallback_project_walk_builder(search_root: &Path) -> WalkBuilder {
513    let mut builder = WalkBuilder::new(search_root);
514    builder
515        .hidden(false)
516        .git_ignore(true)
517        .git_global(true)
518        .git_exclude(true)
519        .add_custom_ignore_filename(".aftignore")
520        .filter_entry(|entry| {
521            let name = entry.file_name().to_string_lossy();
522            if entry.file_type().map_or(false, |ft| ft.is_dir()) {
523                return !matches!(
524                    name.as_ref(),
525                    "node_modules"
526                        | "target"
527                        | "venv"
528                        | ".venv"
529                        | ".git"
530                        | "__pycache__"
531                        | ".tox"
532                        | "dist"
533                        | "build"
534                );
535            }
536            true
537        });
538    builder
539}
540
541/// Bounded project walk used when the trigram index is unavailable (grep/glob fallback).
542pub(crate) fn bounded_fallback_walk_files(
543    filter_root: &Path,
544    search_root: &Path,
545    filters: &PathFilters,
546) -> FallbackWalkOutcome {
547    bounded_fallback_walk_files_with_limits(
548        filter_root,
549        search_root,
550        filters,
551        MAX_FALLBACK_WALK_FILES,
552        FALLBACK_WALK_BUDGET,
553    )
554}
555
556fn bounded_fallback_walk_files_with_limits(
557    filter_root: &Path,
558    search_root: &Path,
559    filters: &PathFilters,
560    max_files: usize,
561    budget: Duration,
562) -> FallbackWalkOutcome {
563    let started = Instant::now();
564    let mut files = Vec::new();
565    let mut walk_truncated = false;
566    let mut entries_visited = 0usize;
567    let builder = fallback_project_walk_builder(search_root);
568
569    for entry in builder.build().filter_map(|entry| entry.ok()) {
570        entries_visited += 1;
571        if started.elapsed() >= budget {
572            walk_truncated = true;
573            break;
574        }
575        if !entry
576            .file_type()
577            .map_or(false, |file_type| file_type.is_file())
578        {
579            continue;
580        }
581        let path = entry.into_path();
582        if filters.matches(filter_root, &path) {
583            files.push(path);
584            if files.len() > max_files {
585                walk_truncated = true;
586                files.truncate(max_files);
587                break;
588            }
589        }
590    }
591
592    sort_paths_by_mtime_desc(&mut files, filter_root);
593    FallbackWalkOutcome {
594        files,
595        walk_truncated,
596        entries_visited,
597    }
598}
599
600pub(crate) fn for_each_bounded_fallback_walk_file<F>(
601    filter_root: &Path,
602    search_root: &Path,
603    filters: &PathFilters,
604    project_root: &Path,
605    path_exclusion: Option<GrepPathExclusion>,
606    mut on_file: F,
607) -> bool
608where
609    F: FnMut(&PathBuf),
610{
611    for_each_bounded_fallback_walk_file_with_limits(
612        filter_root,
613        search_root,
614        filters,
615        project_root,
616        path_exclusion,
617        MAX_FALLBACK_WALK_FILES,
618        FALLBACK_WALK_BUDGET,
619        &mut on_file,
620    )
621}
622
623fn for_each_bounded_fallback_walk_file_with_limits<F>(
624    filter_root: &Path,
625    search_root: &Path,
626    filters: &PathFilters,
627    project_root: &Path,
628    path_exclusion: Option<GrepPathExclusion>,
629    max_files: usize,
630    budget: Duration,
631    on_file: &mut F,
632) -> bool
633where
634    F: FnMut(&PathBuf),
635{
636    let started = Instant::now();
637    let mut files_seen = 0usize;
638    let builder = fallback_project_walk_builder(search_root);
639
640    for entry in builder.build().filter_map(|entry| entry.ok()) {
641        if crate::executor::current_job_cancelled() {
642            return true;
643        }
644        if started.elapsed() >= budget {
645            return true;
646        }
647        if !entry
648            .file_type()
649            .map_or(false, |file_type| file_type.is_file())
650        {
651            continue;
652        }
653        let path = entry.into_path();
654        if path_exclusion.is_some_and(|exclude| exclude(&path, project_root)) {
655            continue;
656        }
657        if filters.matches(filter_root, &path) {
658            files_seen += 1;
659            if files_seen > max_files {
660                return true;
661            }
662            on_file(&path);
663        }
664    }
665    false
666}
667
668pub fn weakest_index_status(left: IndexStatus, right: IndexStatus) -> IndexStatus {
669    match (left, right) {
670        (IndexStatus::Disabled, _) | (_, IndexStatus::Disabled) => IndexStatus::Disabled,
671        (IndexStatus::Fallback, _) | (_, IndexStatus::Fallback) => IndexStatus::Fallback,
672        (IndexStatus::Building, _) | (_, IndexStatus::Building) => IndexStatus::Building,
673        (IndexStatus::Ready, IndexStatus::Ready) => IndexStatus::Ready,
674    }
675}
676
677/// Hidden entry for `search_startup_bench` timing (fallback grep path).
678#[doc(hidden)]
679pub fn fallback_grep_bench(
680    project_root: &Path,
681    search_root: &Path,
682    filter_root: &Path,
683    pattern: &CompiledPattern,
684    include: &[String],
685    exclude: &[String],
686    max_results: usize,
687) -> GrepResult {
688    let filters = build_path_filters(include, exclude).unwrap_or_default();
689    fallback_grep(
690        project_root,
691        search_root,
692        filter_root,
693        pattern,
694        &filters,
695        max_results,
696        IndexStatus::Fallback,
697        None,
698    )
699}
700
701fn fallback_grep(
702    project_root: &Path,
703    search_root: &Path,
704    filter_root: &Path,
705    pattern: &CompiledPattern,
706    filters: &PathFilters,
707    max_results: usize,
708    index_status: IndexStatus,
709    path_exclusion: Option<GrepPathExclusion>,
710) -> GrepResult {
711    let total_matches = AtomicUsize::new(0);
712    let files_searched = AtomicUsize::new(0);
713    let files_with_matches = AtomicUsize::new(0);
714    let truncated = AtomicBool::new(false);
715    let engine_capped = AtomicBool::new(false);
716    let stop_after = max_results.saturating_mul(2);
717    let stop_scan = Arc::new(AtomicBool::new(false));
718    let scan_deadline = Instant::now() + FALLBACK_WALK_BUDGET;
719    let job_cancellation = crate::executor::current_job_cancellation();
720
721    let mut matches = Vec::new();
722    let mut batch: Vec<PathBuf> = Vec::with_capacity(256);
723
724    let flush_batch = |batch: &mut Vec<PathBuf>, matches: &mut Vec<GrepMatch>| {
725        if batch.is_empty() {
726            return;
727        }
728        let chunk = std::mem::take(batch);
729        let partial: Vec<GrepMatch> = chunk
730            .par_iter()
731            .filter_map(|file| {
732                if stop_scan.load(Ordering::Relaxed)
733                    || Instant::now() >= scan_deadline
734                    || job_cancellation
735                        .as_ref()
736                        .is_some_and(|token| token.cancel_requested_before_commit())
737                {
738                    return None;
739                }
740                let file_matches = fallback_search_file(
741                    file,
742                    pattern,
743                    max_results,
744                    stop_after,
745                    &total_matches,
746                    &files_searched,
747                    &files_with_matches,
748                    &truncated,
749                    &engine_capped,
750                    job_cancellation.as_ref(),
751                    Some(scan_deadline),
752                );
753                if truncated.load(Ordering::Relaxed)
754                    && total_matches.load(Ordering::Relaxed) >= stop_after
755                {
756                    stop_scan.store(true, Ordering::Relaxed);
757                }
758                (!file_matches.is_empty()).then_some(file_matches)
759            })
760            .flatten()
761            .collect();
762        matches.extend(partial);
763    };
764
765    let mut walk_truncated = for_each_bounded_fallback_walk_file(
766        filter_root,
767        search_root,
768        filters,
769        project_root,
770        path_exclusion,
771        |path| {
772            if stop_scan.load(Ordering::Relaxed) {
773                return;
774            }
775            batch.push(path.clone());
776            if batch.len() >= 256 {
777                flush_batch(&mut batch, &mut matches);
778            }
779        },
780    );
781    flush_batch(&mut batch, &mut matches);
782    if Instant::now() >= scan_deadline {
783        walk_truncated = true;
784        engine_capped.store(true, Ordering::Relaxed);
785    }
786
787    sort_grep_matches_by_mtime_desc(&mut matches, project_root);
788
789    GrepResult {
790        total_matches: total_matches.load(Ordering::Relaxed),
791        matches,
792        files_searched: files_searched.load(Ordering::Relaxed),
793        files_with_matches: files_with_matches.load(Ordering::Relaxed),
794        index_status,
795        truncated: truncated.load(Ordering::Relaxed),
796        fully_degraded: true,
797        engine_capped: engine_capped.load(Ordering::Relaxed),
798        walk_truncated,
799    }
800}
801
802fn fallback_search_file(
803    file: &PathBuf,
804    pattern: &CompiledPattern,
805    max_results: usize,
806    stop_after: usize,
807    total_matches: &AtomicUsize,
808    files_searched: &AtomicUsize,
809    files_with_matches: &AtomicUsize,
810    truncated: &AtomicBool,
811    engine_capped: &AtomicBool,
812    job_cancellation: Option<&crate::executor::JobCancellation>,
813    deadline: Option<Instant>,
814) -> Vec<GrepMatch> {
815    if deadline.is_some_and(|deadline| Instant::now() >= deadline)
816        || should_stop_fallback_search(truncated, total_matches, stop_after, job_cancellation)
817    {
818        engine_capped.store(true, Ordering::Relaxed);
819        return Vec::new();
820    }
821
822    let Some(content) = read_searchable_text(file) else {
823        return Vec::new();
824    };
825    files_searched.fetch_add(1, Ordering::Relaxed);
826
827    let line_starts = line_starts(&content);
828    let mut seen_lines = HashSet::new();
829    let mut matched_this_file = false;
830    let mut matches = Vec::new();
831
832    match pattern {
833        CompiledPattern::Literal(literal) => search_literal_in_text(
834            file,
835            &content,
836            &line_starts,
837            literal,
838            max_results,
839            stop_after,
840            total_matches,
841            &mut seen_lines,
842            truncated,
843            engine_capped,
844            &mut matched_this_file,
845            &mut matches,
846            job_cancellation,
847            deadline,
848        ),
849        CompiledPattern::Regex { compiled, .. } => {
850            for matched in compiled.find_iter(content.as_bytes()) {
851                if deadline.is_some_and(|deadline| Instant::now() >= deadline)
852                    || should_stop_fallback_search(
853                        truncated,
854                        total_matches,
855                        stop_after,
856                        job_cancellation,
857                    )
858                {
859                    engine_capped.store(true, Ordering::Relaxed);
860                    break;
861                }
862
863                let (line, column, line_text) =
864                    line_details(&content, &line_starts, matched.start());
865                if !seen_lines.insert(line) {
866                    continue;
867                }
868
869                matched_this_file = true;
870                let match_number = total_matches.fetch_add(1, Ordering::Relaxed) + 1;
871                if match_number > max_results {
872                    truncated.store(true, Ordering::Relaxed);
873                    break;
874                }
875
876                matches.push(GrepMatch {
877                    file: file.clone(),
878                    line,
879                    column,
880                    line_text,
881                    match_text: String::from_utf8_lossy(matched.as_bytes()).into_owned(),
882                });
883            }
884        }
885    }
886
887    if matched_this_file {
888        files_with_matches.fetch_add(1, Ordering::Relaxed);
889    }
890
891    matches
892}
893
894fn search_literal_in_text(
895    file: &Path,
896    content: &str,
897    line_starts: &[usize],
898    literal: &LiteralSearch,
899    max_results: usize,
900    stop_after: usize,
901    total_matches: &AtomicUsize,
902    seen_lines: &mut HashSet<u32>,
903    truncated: &AtomicBool,
904    engine_capped: &AtomicBool,
905    matched_this_file: &mut bool,
906    matches: &mut Vec<GrepMatch>,
907    job_cancellation: Option<&crate::executor::JobCancellation>,
908    deadline: Option<Instant>,
909) {
910    let content_bytes = content.as_bytes();
911    let search_content;
912    let haystack = if literal.case_insensitive_ascii {
913        search_content = content_bytes.to_ascii_lowercase();
914        search_content.as_slice()
915    } else {
916        content_bytes
917    };
918    let finder = memchr::memmem::Finder::new(&literal.needle);
919    let mut start = 0usize;
920
921    while let Some(position) = finder.find(&haystack[start..]) {
922        if deadline.is_some_and(|deadline| Instant::now() >= deadline)
923            || should_stop_fallback_search(truncated, total_matches, stop_after, job_cancellation)
924        {
925            engine_capped.store(true, Ordering::Relaxed);
926            break;
927        }
928
929        let offset = start + position;
930        start = offset + 1;
931        let (line, column, line_text) = line_details(content, line_starts, offset);
932        if !seen_lines.insert(line) {
933            continue;
934        }
935
936        *matched_this_file = true;
937        let match_number = total_matches.fetch_add(1, Ordering::Relaxed) + 1;
938        if match_number > max_results {
939            truncated.store(true, Ordering::Relaxed);
940            break;
941        }
942
943        let end = offset + literal.needle.len();
944        matches.push(GrepMatch {
945            file: file.to_path_buf(),
946            line,
947            column,
948            line_text,
949            match_text: String::from_utf8_lossy(&content_bytes[offset..end]).into_owned(),
950        });
951    }
952}
953
954fn should_stop_fallback_search(
955    truncated: &AtomicBool,
956    total_matches: &AtomicUsize,
957    stop_after: usize,
958    job_cancellation: Option<&crate::executor::JobCancellation>,
959) -> bool {
960    job_cancellation.is_some_and(|token| token.cancel_requested_before_commit())
961        || (truncated.load(Ordering::Relaxed)
962            && total_matches.load(Ordering::Relaxed) >= stop_after)
963}
964
965pub(crate) fn ripgrep_glob(
966    search_root: &Path,
967    pattern: &str,
968    max_results: usize,
969) -> Option<FallbackWalkOutcome> {
970    let filters = build_path_filters(&[pattern.to_string()], &[]).ok()?;
971    let mut outcome = bounded_fallback_walk_files(search_root, search_root, &filters);
972    outcome.files.truncate(max_results);
973    Some(outcome)
974}
975
976fn current_index_status(ctx: &AppContext) -> IndexStatus {
977    let Some(search_index) =
978        try_read_with_budget(ctx.search_index(), INTERACTIVE_ARTIFACT_READ_BUDGET)
979    else {
980        return IndexStatus::Fallback;
981    };
982    if search_index.as_ref().is_some_and(|index| index.ready) {
983        return IndexStatus::Ready;
984    }
985
986    let build_in_progress =
987        try_read_with_budget(ctx.search_index_rx(), INTERACTIVE_ARTIFACT_READ_BUDGET)
988            .is_some_and(|search_index_rx| search_index_rx.is_some());
989    if build_in_progress || search_index.is_some() {
990        IndexStatus::Building
991    } else {
992        IndexStatus::Fallback
993    }
994}
995
996pub fn line_starts(content: &str) -> Vec<usize> {
997    let mut starts = vec![0usize];
998    for (index, byte) in content.bytes().enumerate() {
999        if byte == b'\n' {
1000            starts.push(index + 1);
1001        }
1002    }
1003    starts
1004}
1005
1006/// Floor a byte index to the nearest valid `str` char boundary (never panics).
1007pub fn floor_char_boundary_str(content: &str, mut index: usize) -> usize {
1008    index = index.min(content.len());
1009    while index > 0 && !content.is_char_boundary(index) {
1010        index -= 1;
1011    }
1012    index
1013}
1014
1015/// Prefix of `content` with at most `max_bytes` UTF-8 bytes, truncated on a char boundary.
1016pub fn truncate_at_char_boundary(content: &str, max_bytes: usize) -> &str {
1017    let end = floor_char_boundary_str(content, max_bytes);
1018    &content[..end]
1019}
1020
1021pub fn line_details(content: &str, line_starts: &[usize], offset: usize) -> (u32, u32, String) {
1022    let offset = floor_char_boundary_str(content, offset);
1023    let line_index = match line_starts.binary_search(&offset) {
1024        Ok(index) => index,
1025        Err(index) => index.saturating_sub(1),
1026    };
1027    let line_start = line_starts.get(line_index).copied().unwrap_or(0);
1028    let line_end = content[line_start..]
1029        .find('\n')
1030        .map(|length| line_start + length)
1031        .unwrap_or(content.len());
1032    let line_text = content[line_start..line_end]
1033        .trim_end_matches('\r')
1034        .to_string();
1035    let column = content[line_start..offset].chars().count() as u32 + 1;
1036    (line_index as u32 + 1, column, line_text)
1037}
1038
1039#[cfg(test)]
1040mod tests {
1041    use super::*;
1042
1043    fn grep_match(file: &Path, line: u32, column: u32) -> GrepMatch {
1044        GrepMatch {
1045            file: file.to_path_buf(),
1046            line,
1047            column,
1048            line_text: "needle".to_string(),
1049            match_text: "needle".to_string(),
1050        }
1051    }
1052
1053    fn result(matches: Vec<GrepMatch>, truncated: bool, status: IndexStatus) -> GrepResult {
1054        GrepResult {
1055            total_matches: matches.len(),
1056            files_searched: matches.len(),
1057            files_with_matches: matches.len(),
1058            matches,
1059            index_status: status,
1060            truncated,
1061            fully_degraded: false,
1062            engine_capped: false,
1063            walk_truncated: false,
1064        }
1065    }
1066
1067    #[test]
1068    fn optional_path_exclusion_controls_visible_totals_without_affecting_default_grep() {
1069        fn excludes_tests(path: &Path, root: &Path) -> bool {
1070            path.strip_prefix(root)
1071                .is_ok_and(|relative| relative.starts_with("tests"))
1072        }
1073
1074        let project = tempfile::tempdir().expect("project");
1075        let test_file = project.path().join("tests/case.rs");
1076        let source_file = project.path().join("src/lib.rs");
1077        std::fs::create_dir_all(test_file.parent().expect("test parent")).expect("test dir");
1078        std::fs::create_dir_all(source_file.parent().expect("source parent")).expect("source dir");
1079        std::fs::write(&test_file, "const NEEDLE: &str = \"needle\";\n").expect("test file");
1080        std::fs::write(&source_file, "pub fn needle() {}\n").expect("source file");
1081        let pattern = match crate::pattern_compile::compile(
1082            "needle",
1083            crate::pattern_compile::CompileOpts {
1084                literal: true,
1085                ..crate::pattern_compile::CompileOpts::default()
1086            },
1087        ) {
1088            crate::pattern_compile::CompileResult::Ok(pattern) => pattern,
1089            other => panic!("compile literal: {other:?}"),
1090        };
1091
1092        let filters = PathFilters::default();
1093        let unfiltered = fallback_grep(
1094            project.path(),
1095            project.path(),
1096            project.path(),
1097            &pattern,
1098            &filters,
1099            10,
1100            IndexStatus::Fallback,
1101            None,
1102        );
1103        assert_eq!(unfiltered.total_matches, 2);
1104        assert_eq!(unfiltered.matches.len(), 2);
1105
1106        let visible = fallback_grep(
1107            project.path(),
1108            project.path(),
1109            project.path(),
1110            &pattern,
1111            &filters,
1112            10,
1113            IndexStatus::Fallback,
1114            Some(excludes_tests),
1115        );
1116        assert_eq!(visible.total_matches, 1);
1117        assert_eq!(visible.matches.len(), 1);
1118        assert_eq!(visible.files_searched, 1);
1119        assert_eq!(visible.files_with_matches, 1);
1120        assert_eq!(visible.matches[0].file, source_file);
1121        assert!(!visible.truncated);
1122        assert!(!visible.engine_capped);
1123    }
1124
1125    #[test]
1126    fn single_root_uses_requested_max() {
1127        let scope = GrepScope {
1128            roots: vec![ResolvedRoot {
1129                search_root: PathBuf::from("/project"),
1130                filter_root: PathBuf::from("/project"),
1131                use_index: true,
1132                is_external: false,
1133            }],
1134            multi_root: false,
1135            per_root_max: 10,
1136        };
1137        assert!(!scope.multi_root);
1138        assert_eq!(scope.per_root_max, 10);
1139    }
1140
1141    #[test]
1142    fn multi_root_uses_double_per_root_max() {
1143        let project = tempfile::tempdir().expect("project");
1144        let ctx = AppContext::new(
1145            Box::new(crate::parser::TreeSitterProvider::new()),
1146            crate::config::Config {
1147                project_root: Some(project.path().to_path_buf()),
1148                ..crate::config::Config::default()
1149            },
1150        );
1151        let left = project.path().join("left");
1152        let right = project.path().join("right");
1153        std::fs::create_dir_all(&left).expect("left");
1154        std::fs::create_dir_all(&right).expect("right");
1155        let paths = serde_json::json!([left.display().to_string(), right.display().to_string()]);
1156
1157        let scope = resolve_grep_scope(&ctx, Some(&paths), 10, "test").expect("scope");
1158
1159        assert!(scope.multi_root);
1160        assert_eq!(scope.per_root_max, 20);
1161    }
1162
1163    #[test]
1164    fn bounded_fallback_walk_truncates_at_file_cap() {
1165        let dir = tempfile::tempdir().expect("tempdir");
1166        let root = dir.path();
1167        for i in 0..25 {
1168            let path = root.join(format!("file_{i:03}.txt"));
1169            std::fs::write(path, "needle\n").expect("write");
1170        }
1171        let filters = build_path_filters(&["**/*.txt".to_string()], &[]).expect("filters");
1172        let outcome = bounded_fallback_walk_files_with_limits(
1173            root,
1174            root,
1175            &filters,
1176            20,
1177            Duration::from_secs(60),
1178        );
1179        assert!(outcome.walk_truncated);
1180        assert_eq!(outcome.files.len(), 20);
1181    }
1182
1183    #[test]
1184    fn bounded_fallback_walk_small_tree_not_truncated() {
1185        let dir = tempfile::tempdir().expect("tempdir");
1186        let root = dir.path();
1187        std::fs::write(root.join("a.txt"), "x\n").expect("write");
1188        std::fs::write(root.join("b.txt"), "x\n").expect("write");
1189        let filters = build_path_filters(&["**/*.txt".to_string()], &[]).expect("filters");
1190        let outcome = bounded_fallback_walk_files(root, root, &filters);
1191        assert!(!outcome.walk_truncated);
1192        assert_eq!(outcome.files.len(), 2);
1193    }
1194
1195    #[test]
1196    fn filter_root_is_project_for_in_project_and_search_root_for_external_unindexed() {
1197        let project = PathBuf::from("/project");
1198        let in_project = compute_filter_root(&project, Path::new("/project/src"), true, false);
1199        let external = compute_filter_root(&project, Path::new("/tmp/external"), false, true);
1200        assert_eq!(in_project, project);
1201        assert_eq!(external, PathBuf::from("/tmp/external"));
1202    }
1203
1204    #[test]
1205    fn weakest_status_orders_disabled_fallback_building_ready() {
1206        assert_eq!(
1207            weakest_index_status(IndexStatus::Ready, IndexStatus::Building),
1208            IndexStatus::Building
1209        );
1210        assert_eq!(
1211            weakest_index_status(IndexStatus::Building, IndexStatus::Fallback),
1212            IndexStatus::Fallback
1213        );
1214        assert_eq!(
1215            weakest_index_status(IndexStatus::Fallback, IndexStatus::Disabled),
1216            IndexStatus::Disabled
1217        );
1218    }
1219
1220    #[test]
1221    fn merge_dedupes_by_canonical_file_line_column() {
1222        let temp = tempfile::tempdir().expect("temp");
1223        let file = temp.path().join("file.rs");
1224        std::fs::write(&file, "needle").expect("write");
1225        let symlink = temp.path().join("link.rs");
1226        #[cfg(unix)]
1227        std::os::unix::fs::symlink(&file, &symlink).expect("symlink");
1228        #[cfg(windows)]
1229        std::os::windows::fs::symlink_file(&file, &symlink).expect("symlink");
1230
1231        let merged = merge_grep_results(
1232            vec![
1233                result(vec![grep_match(&file, 1, 1)], false, IndexStatus::Ready),
1234                result(vec![grep_match(&symlink, 1, 1)], false, IndexStatus::Ready),
1235            ],
1236            temp.path(),
1237            10,
1238        );
1239
1240        assert_eq!(merged.matches.len(), 1);
1241    }
1242
1243    #[test]
1244    fn merge_truncated_when_child_truncated_or_pre_merge_exceeds_max() {
1245        let root = Path::new("/project");
1246        let child = merge_grep_results(
1247            vec![result(
1248                vec![grep_match(Path::new("/project/a.rs"), 1, 1)],
1249                true,
1250                IndexStatus::Ready,
1251            )],
1252            root,
1253            10,
1254        );
1255        assert!(child.truncated);
1256
1257        let many = merge_grep_results(
1258            vec![
1259                result(
1260                    vec![grep_match(Path::new("/project/a.rs"), 1, 1)],
1261                    false,
1262                    IndexStatus::Ready,
1263                ),
1264                result(
1265                    vec![grep_match(Path::new("/project/b.rs"), 1, 1)],
1266                    false,
1267                    IndexStatus::Ready,
1268                ),
1269            ],
1270            root,
1271            1,
1272        );
1273        assert!(many.truncated);
1274    }
1275
1276    #[test]
1277    fn line_details_floors_offset_inside_multibyte_char() {
1278        let content = "before—after";
1279        let starts = line_starts(content);
1280        let dash_byte = content.find('—').expect("em dash");
1281        let mid_byte = dash_byte + 1;
1282        assert!(!content.is_char_boundary(mid_byte));
1283        let (line, column, line_text) = line_details(content, &starts, mid_byte);
1284        assert_eq!(line, 1);
1285        assert_eq!(column, content[..dash_byte].chars().count() as u32 + 1);
1286        assert!(line_text.contains('—'));
1287    }
1288
1289    #[test]
1290    fn line_details_clamps_offset_past_end() {
1291        let content = "short";
1292        let starts = line_starts(content);
1293        let (line, column, _) = line_details(content, &starts, content.len() + 100);
1294        assert_eq!(line, 1);
1295        assert_eq!(column, 6);
1296    }
1297
1298    #[test]
1299    fn truncate_at_char_boundary_floors_mid_multibyte_at_byte_cap() {
1300        let mut prefix = "a".repeat(38);
1301        prefix.push('—');
1302        prefix.push_str("tail");
1303        assert_eq!(prefix.len(), 45);
1304        assert!(!prefix.is_char_boundary(40));
1305        let truncated = truncate_at_char_boundary(&prefix, 40);
1306        assert!(truncated.is_char_boundary(truncated.len()));
1307        assert!(truncated.ends_with('a'));
1308        assert!(!truncated.contains('—'));
1309    }
1310
1311    #[test]
1312    fn regex_byte_match_start_mid_char_does_not_panic_in_line_details() {
1313        use crate::pattern_compile::{CompileOpts, CompileResult};
1314
1315        let content = "xy—zz";
1316        let starts = line_starts(content);
1317        let compiled = match crate::pattern_compile::compile(
1318            ".",
1319            CompileOpts {
1320                multi_line: false,
1321                ..CompileOpts::default()
1322            },
1323        ) {
1324            CompileResult::Ok(compiled) => compiled,
1325            other => panic!("expected compiled pattern, got {other:?}"),
1326        };
1327        let crate::pattern_compile::CompiledPattern::Regex { compiled, .. } = compiled else {
1328            panic!("expected regex pattern");
1329        };
1330        for matched in compiled.find_iter(content.as_bytes()) {
1331            let _ = line_details(content, &starts, matched.start());
1332        }
1333    }
1334
1335    fn compiled_regex(pattern: &str) -> CompiledPattern {
1336        match crate::pattern_compile::compile(
1337            pattern,
1338            crate::pattern_compile::CompileOpts::default(),
1339        ) {
1340            crate::pattern_compile::CompileResult::Ok(compiled) => compiled,
1341            other => panic!("compile regex {pattern:?}: {other:?}"),
1342        }
1343    }
1344
1345    fn grep_result_bytes(result: &GrepResult) -> Vec<u8> {
1346        serde_json::to_vec(&serde_json::json!({
1347            "matches": result.matches.iter().map(|matched| serde_json::json!({
1348                "file": matched.file,
1349                "line": matched.line,
1350                "column": matched.column,
1351                "line_text": matched.line_text,
1352                "match_text": matched.match_text,
1353            })).collect::<Vec<_>>(),
1354            "total_matches": result.total_matches,
1355            "files_searched": result.files_searched,
1356            "files_with_matches": result.files_with_matches,
1357            "index_status": result.index_status.as_str(),
1358            "truncated": result.truncated,
1359            "fully_degraded": result.fully_degraded,
1360            "engine_capped": result.engine_capped,
1361            "walk_truncated": result.walk_truncated,
1362        }))
1363        .expect("serialize grep result projection")
1364    }
1365
1366    #[test]
1367    fn multi_root_shared_query_matches_per_root_query_bytes() {
1368        let project = tempfile::tempdir().expect("project");
1369        let root_names = ["api", "cli", "daemon", "worker"];
1370        let roots = root_names
1371            .iter()
1372            .map(|name| {
1373                let root = project.path().join(name);
1374                std::fs::create_dir_all(&root).expect("create root");
1375                std::fs::write(
1376                    root.join("service.rs"),
1377                    "fn needle_alpha_12() {}\nfn needle_beta_34() {}\n",
1378                )
1379                .expect("write fixture");
1380                std::fs::canonicalize(root).expect("canonicalize root")
1381            })
1382            .collect::<Vec<_>>();
1383        let pattern = compiled_regex(r"needle_(?:alpha|beta)_\d+");
1384        let filters = PathFilters::default();
1385        let params = GrepParams {
1386            include: Vec::new(),
1387            exclude: Vec::new(),
1388            max_results: 100,
1389            path_exclusion: None,
1390        };
1391        let scope = GrepScope {
1392            roots: roots
1393                .iter()
1394                .map(|root| ResolvedRoot {
1395                    search_root: root.clone(),
1396                    filter_root: project.path().to_path_buf(),
1397                    use_index: true,
1398                    is_external: false,
1399                })
1400                .collect(),
1401            multi_root: true,
1402            per_root_max: 200,
1403        };
1404        let index =
1405            crate::search_index::SearchIndex::build_with_limit_serial(project.path(), 1_048_576);
1406        let snapshot = index.snapshot();
1407        let expected = merge_grep_results(
1408            scope
1409                .roots
1410                .iter()
1411                .map(|root| {
1412                    snapshot
1413                        .search_grep_profiled_with_filters(
1414                            &pattern,
1415                            &filters,
1416                            &root.search_root,
1417                            scope.per_root_max,
1418                            None,
1419                        )
1420                        .0
1421                })
1422                .collect(),
1423            project.path(),
1424            params.max_results,
1425        );
1426        let ctx = AppContext::new(
1427            Box::new(crate::parser::TreeSitterProvider::new()),
1428            crate::config::Config {
1429                project_root: Some(project.path().to_path_buf()),
1430                ..crate::config::Config::default()
1431            },
1432        );
1433        *ctx.search_index().write().expect("lock search index") = Some(index);
1434
1435        let (actual, phases) =
1436            execute_profiled_with_filters(&ctx, &pattern, &scope, &params, &filters);
1437
1438        assert_eq!(
1439            grep_result_bytes(&actual),
1440            grep_result_bytes(&expected),
1441            "shared query search must preserve the legacy per-root result bytes"
1442        );
1443        assert!(
1444            !phases.query_decomposition.is_zero(),
1445            "the indexed request must record its one-time query decomposition"
1446        );
1447    }
1448
1449    /// Manual release-mode probe for a warm indexed TypeScript corpus searched across four roots.
1450    #[test]
1451    #[ignore = "manual release-mode issue #219 multi-root query performance probe"]
1452    fn issue_219_multi_root_query_reuse_perf_probe() {
1453        const ROOTS: usize = 4;
1454        const FILES_PER_ROOT: usize = 1_000;
1455        const SAMPLES: usize = 9;
1456        const ITERATIONS: usize = 300;
1457
1458        let project_root = PathBuf::from("/tmp/aft-issue-219-multi-root");
1459        let mut index = crate::search_index::SearchIndex::new();
1460        let roots = (0..ROOTS)
1461            .map(|root_index| project_root.join(format!("packages/root-{root_index}")))
1462            .collect::<Vec<_>>();
1463        for root in &roots {
1464            for file_index in 0..FILES_PER_ROOT {
1465                index.index_file(
1466                    &root.join(format!("src/module-{file_index:04}.ts")),
1467                    b"export const indexed_value = 'warm corpus';\n",
1468                );
1469            }
1470        }
1471        let snapshot = index.snapshot();
1472        let pattern =
1473            compiled_regex(r"(?:(?:parse|format|validate)_[A-Za-z0-9_]+_)?issue_219_never_present");
1474        let filters = PathFilters::default();
1475
1476        let per_root_once = || {
1477            for root in &roots {
1478                let result = snapshot
1479                    .search_grep_profiled_with_filters(&pattern, &filters, root, 100, None)
1480                    .0;
1481                std::hint::black_box(result.total_matches);
1482            }
1483        };
1484        let shared_query_once = || {
1485            let query = decompose_grep_pattern(&pattern);
1486            for root in &roots {
1487                let result = snapshot
1488                    .search_grep_profiled_with_filters_and_query(
1489                        &pattern, &query, &filters, root, 100, None,
1490                    )
1491                    .0;
1492                std::hint::black_box(result.total_matches);
1493            }
1494        };
1495
1496        let mut per_root_ns = Vec::with_capacity(SAMPLES);
1497        let mut shared_query_ns = Vec::with_capacity(SAMPLES);
1498        for sample in 0..SAMPLES {
1499            let measure = |operation: &dyn Fn()| {
1500                let started = Instant::now();
1501                for _ in 0..ITERATIONS {
1502                    operation();
1503                }
1504                started.elapsed().as_nanos() / ITERATIONS as u128
1505            };
1506            if sample % 2 == 0 {
1507                per_root_ns.push(measure(&per_root_once));
1508                shared_query_ns.push(measure(&shared_query_once));
1509            } else {
1510                shared_query_ns.push(measure(&shared_query_once));
1511                per_root_ns.push(measure(&per_root_once));
1512            }
1513        }
1514        per_root_ns.sort_unstable();
1515        shared_query_ns.sort_unstable();
1516        let per_root_median = per_root_ns[SAMPLES / 2];
1517        let shared_query_median = shared_query_ns[SAMPLES / 2];
1518        let speedup = per_root_median as f64 / shared_query_median as f64;
1519
1520        eprintln!(
1521            "issue #219 multi-root regex query: roots={ROOTS} files_per_root={FILES_PER_ROOT} samples={SAMPLES} iterations={ITERATIONS}"
1522        );
1523        eprintln!("per-root decomposition ns/op samples: {per_root_ns:?}");
1524        eprintln!("shared decomposition ns/op samples: {shared_query_ns:?}");
1525        eprintln!(
1526            "median: per-root={per_root_median}ns shared={shared_query_median}ns speedup={speedup:.2}x"
1527        );
1528    }
1529}