Skip to main content

aft/
grep_executor.rs

1use std::collections::HashSet;
2use std::env;
3use std::path::{Path, PathBuf};
4use std::process::Command;
5use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
6
7use rayon::prelude::*;
8
9use crate::commands::multi_path::{
10    canonical_key, dedupe_nested_paths, resolve_path_or_multi, SearchPathResolution,
11};
12use crate::context::AppContext;
13use crate::pattern_compile::{CompiledPattern, LiteralSearch};
14use crate::protocol::Response;
15use crate::search_index::{
16    build_path_filters, read_searchable_text, resolve_search_scope,
17    sort_grep_matches_by_mtime_desc, walk_project_files_from, GrepMatch, GrepResult, IndexStatus,
18};
19
20#[derive(Clone, Debug)]
21pub struct GrepParams {
22    pub include: Vec<String>,
23    pub exclude: Vec<String>,
24    pub max_results: usize,
25}
26
27#[derive(Clone, Debug)]
28pub struct GrepScope {
29    pub roots: Vec<ResolvedRoot>,
30    pub multi_root: bool,
31    pub per_root_max: usize,
32}
33
34#[derive(Clone, Debug)]
35pub struct ResolvedRoot {
36    pub search_root: PathBuf,
37    pub filter_root: PathBuf,
38    pub use_index: bool,
39    pub is_external: bool,
40}
41
42pub fn project_root(ctx: &AppContext) -> PathBuf {
43    let project_root = ctx
44        .config()
45        .project_root
46        .clone()
47        .unwrap_or_else(|| env::current_dir().unwrap_or_default());
48    std::fs::canonicalize(&project_root).unwrap_or(project_root)
49}
50
51pub fn resolve_grep_scope(
52    ctx: &AppContext,
53    paths: Option<&serde_json::Value>,
54    max_results: usize,
55    req_id: &str,
56) -> Result<GrepScope, Response> {
57    let project_root = project_root(ctx);
58    let search_roots = resolve_roots(ctx, paths, &project_root, req_id)?;
59
60    if let Some(missing_root) = search_roots.iter().find(|root| !root.exists()) {
61        return Err(Response::error(
62            req_id,
63            "path_not_found",
64            format!(
65                "grep: search path does not exist: {}",
66                missing_root.display()
67            ),
68        ));
69    }
70
71    let roots = search_roots
72        .into_iter()
73        .map(|search_root| {
74            let scope = resolve_search_scope(&project_root, Some(&search_root.to_string_lossy()));
75            let is_external = !scope.use_index;
76            let filter_root =
77                compute_filter_root(&project_root, &scope.root, scope.use_index, is_external);
78            ResolvedRoot {
79                search_root: scope.root,
80                filter_root,
81                use_index: scope.use_index,
82                is_external,
83            }
84        })
85        .collect::<Vec<_>>();
86
87    let multi_root = roots.len() > 1;
88    let per_root_max = if multi_root {
89        max_results.saturating_mul(2).max(max_results)
90    } else {
91        max_results
92    };
93
94    Ok(GrepScope {
95        roots,
96        multi_root,
97        per_root_max,
98    })
99}
100
101pub fn compute_filter_root(
102    project_root: &Path,
103    search_root: &Path,
104    use_index: bool,
105    is_external: bool,
106) -> PathBuf {
107    if is_external && !use_index {
108        search_root.to_path_buf()
109    } else {
110        project_root.to_path_buf()
111    }
112}
113
114pub fn scope_has_files(project_root: &Path, scope: &GrepScope) -> bool {
115    scope.roots.iter().any(|root| {
116        walk_project_files_from(
117            &root.filter_root,
118            &root.search_root,
119            &build_path_filters(&["**/*".to_string()], &[]).expect("valid catch-all glob"),
120        )
121        .into_iter()
122        .next()
123        .is_some()
124            || walk_project_files_from(
125                project_root,
126                &root.search_root,
127                &build_path_filters(&["**/*".to_string()], &[]).expect("valid catch-all glob"),
128            )
129            .into_iter()
130            .next()
131            .is_some()
132    })
133}
134
135pub fn execute(
136    ctx: &AppContext,
137    pattern: &CompiledPattern,
138    scope: &GrepScope,
139    params: &GrepParams,
140) -> GrepResult {
141    let project_root = project_root(ctx);
142    if scope.roots.len() == 1 {
143        return execute_root(
144            ctx,
145            pattern,
146            &scope.roots[0],
147            params,
148            params.max_results,
149            &project_root,
150        );
151    }
152
153    let mut results = Vec::new();
154    for root in &scope.roots {
155        results.push(execute_root(
156            ctx,
157            pattern,
158            root,
159            params,
160            scope.per_root_max,
161            &project_root,
162        ));
163    }
164    merge_grep_results(results, &project_root, params.max_results)
165}
166
167fn resolve_roots(
168    ctx: &AppContext,
169    paths: Option<&serde_json::Value>,
170    project_root: &Path,
171    req_id: &str,
172) -> Result<Vec<PathBuf>, Response> {
173    let Some(paths) = paths else {
174        return Ok(vec![resolve_search_scope(project_root, None).root]);
175    };
176    if paths.is_null() {
177        return Ok(vec![resolve_search_scope(project_root, None).root]);
178    }
179    if let Some(path) = paths.as_str() {
180        return match resolve_path_or_multi(
181            path,
182            project_root,
183            |candidate| ctx.validate_path(req_id, candidate),
184            req_id,
185        )? {
186            SearchPathResolution::Single(root) => Ok(vec![root]),
187            SearchPathResolution::Multi(roots) => Ok(roots),
188        };
189    }
190    if let Some(items) = paths.as_array() {
191        let mut roots = Vec::with_capacity(items.len());
192        for item in items {
193            let Some(path) = item.as_str() else {
194                return Err(Response::error(
195                    req_id,
196                    "invalid_request",
197                    "grep: path array entries must be strings",
198                ));
199            };
200            let validated = ctx.validate_path(req_id, Path::new(path))?;
201            let raw = validated.to_string_lossy();
202            roots.push(resolve_search_scope(project_root, Some(raw.as_ref())).root);
203        }
204        let roots = dedupe_nested_paths(roots);
205        if roots.is_empty() {
206            Ok(vec![resolve_search_scope(project_root, None).root])
207        } else {
208            Ok(roots)
209        }
210    } else {
211        Err(Response::error(
212            req_id,
213            "invalid_request",
214            "grep: path must be a string, array of strings, or null",
215        ))
216    }
217}
218
219fn execute_root(
220    ctx: &AppContext,
221    pattern: &CompiledPattern,
222    root: &ResolvedRoot,
223    params: &GrepParams,
224    max_results: usize,
225    project_root: &Path,
226) -> GrepResult {
227    let search_index = ctx.search_index().borrow();
228    match search_index.as_ref() {
229        Some(index) if index.ready && root.use_index => index.search_grep(
230            pattern,
231            &params.include,
232            &params.exclude,
233            &root.search_root,
234            max_results,
235        ),
236        _ => {
237            if !root.use_index {
238                if let Some(result) = ripgrep_grep(
239                    &root.search_root,
240                    pattern,
241                    &params.include,
242                    &params.exclude,
243                    max_results,
244                ) {
245                    return result;
246                }
247            }
248            let index_status = if root.use_index {
249                current_index_status(ctx)
250            } else {
251                IndexStatus::Fallback
252            };
253            fallback_grep(
254                project_root,
255                &root.search_root,
256                &root.filter_root,
257                pattern,
258                &params.include,
259                &params.exclude,
260                max_results,
261                index_status,
262            )
263        }
264    }
265}
266
267pub fn merge_grep_results(
268    results: Vec<GrepResult>,
269    project_root: &Path,
270    max_results: usize,
271) -> GrepResult {
272    let mut matches = Vec::new();
273    let mut total_matches = 0usize;
274    let mut files_searched = 0usize;
275    let mut files_with_matches = 0usize;
276    let mut index_status = IndexStatus::Ready;
277    let mut any_child_truncated = false;
278    let mut fully_degraded = false;
279    let mut engine_capped = false;
280    let mut seen_match_keys = HashSet::new();
281
282    for result in results {
283        total_matches += result.total_matches;
284        files_searched += result.files_searched;
285        files_with_matches += result.files_with_matches;
286        index_status = weakest_index_status(index_status, result.index_status);
287        any_child_truncated |= result.truncated;
288        fully_degraded |= result.fully_degraded;
289        engine_capped |= result.engine_capped;
290
291        for grep_match in result.matches {
292            let file_key = canonical_key(&grep_match.file);
293            let match_key = (file_key, grep_match.line, grep_match.column);
294            if seen_match_keys.insert(match_key) {
295                matches.push(grep_match);
296            }
297        }
298    }
299
300    sort_grep_matches_by_mtime_desc(&mut matches, project_root);
301    if matches.len() > max_results {
302        matches.truncate(max_results);
303    }
304
305    GrepResult {
306        matches,
307        total_matches,
308        files_searched,
309        files_with_matches,
310        index_status,
311        truncated: any_child_truncated || total_matches > max_results,
312        fully_degraded,
313        engine_capped,
314    }
315}
316
317pub fn weakest_index_status(left: IndexStatus, right: IndexStatus) -> IndexStatus {
318    match (left, right) {
319        (IndexStatus::Disabled, _) | (_, IndexStatus::Disabled) => IndexStatus::Disabled,
320        (IndexStatus::Fallback, _) | (_, IndexStatus::Fallback) => IndexStatus::Fallback,
321        (IndexStatus::Building, _) | (_, IndexStatus::Building) => IndexStatus::Building,
322        (IndexStatus::Ready, IndexStatus::Ready) => IndexStatus::Ready,
323    }
324}
325
326fn fallback_grep(
327    project_root: &Path,
328    search_root: &Path,
329    filter_root: &Path,
330    pattern: &CompiledPattern,
331    include: &[String],
332    exclude: &[String],
333    max_results: usize,
334    index_status: IndexStatus,
335) -> GrepResult {
336    let filters = build_path_filters(include, exclude).unwrap_or_default();
337    let files = walk_project_files_from(filter_root, search_root, &filters);
338
339    let total_matches = AtomicUsize::new(0);
340    let files_searched = AtomicUsize::new(0);
341    let files_with_matches = AtomicUsize::new(0);
342    let truncated = AtomicBool::new(false);
343    let engine_capped = AtomicBool::new(false);
344    let stop_after = max_results.saturating_mul(2);
345
346    let mut matches = files
347        .par_iter()
348        .map(|file| {
349            fallback_search_file(
350                file,
351                pattern,
352                max_results,
353                stop_after,
354                &total_matches,
355                &files_searched,
356                &files_with_matches,
357                &truncated,
358                &engine_capped,
359            )
360        })
361        .reduce(Vec::new, |mut left, mut right| {
362            left.append(&mut right);
363            left
364        });
365
366    sort_grep_matches_by_mtime_desc(&mut matches, project_root);
367
368    GrepResult {
369        total_matches: total_matches.load(Ordering::Relaxed),
370        matches,
371        files_searched: files_searched.load(Ordering::Relaxed),
372        files_with_matches: files_with_matches.load(Ordering::Relaxed),
373        index_status,
374        truncated: truncated.load(Ordering::Relaxed),
375        fully_degraded: true,
376        engine_capped: engine_capped.load(Ordering::Relaxed),
377    }
378}
379
380fn fallback_search_file(
381    file: &PathBuf,
382    pattern: &CompiledPattern,
383    max_results: usize,
384    stop_after: usize,
385    total_matches: &AtomicUsize,
386    files_searched: &AtomicUsize,
387    files_with_matches: &AtomicUsize,
388    truncated: &AtomicBool,
389    engine_capped: &AtomicBool,
390) -> Vec<GrepMatch> {
391    if should_stop_fallback_search(truncated, total_matches, stop_after) {
392        engine_capped.store(true, Ordering::Relaxed);
393        return Vec::new();
394    }
395
396    let Some(content) = read_searchable_text(file) else {
397        return Vec::new();
398    };
399    files_searched.fetch_add(1, Ordering::Relaxed);
400
401    let line_starts = line_starts(&content);
402    let mut seen_lines = HashSet::new();
403    let mut matched_this_file = false;
404    let mut matches = Vec::new();
405
406    match pattern {
407        CompiledPattern::Literal(literal) => search_literal_in_text(
408            file,
409            &content,
410            &line_starts,
411            literal,
412            max_results,
413            stop_after,
414            total_matches,
415            &mut seen_lines,
416            truncated,
417            engine_capped,
418            &mut matched_this_file,
419            &mut matches,
420        ),
421        CompiledPattern::Regex { compiled, .. } => {
422            for matched in compiled.find_iter(content.as_bytes()) {
423                if should_stop_fallback_search(truncated, total_matches, stop_after) {
424                    engine_capped.store(true, Ordering::Relaxed);
425                    break;
426                }
427
428                let (line, column, line_text) =
429                    line_details(&content, &line_starts, matched.start());
430                if !seen_lines.insert(line) {
431                    continue;
432                }
433
434                matched_this_file = true;
435                let match_number = total_matches.fetch_add(1, Ordering::Relaxed) + 1;
436                if match_number > max_results {
437                    truncated.store(true, Ordering::Relaxed);
438                    break;
439                }
440
441                matches.push(GrepMatch {
442                    file: file.clone(),
443                    line,
444                    column,
445                    line_text,
446                    match_text: String::from_utf8_lossy(matched.as_bytes()).into_owned(),
447                });
448            }
449        }
450    }
451
452    if matched_this_file {
453        files_with_matches.fetch_add(1, Ordering::Relaxed);
454    }
455
456    matches
457}
458
459fn search_literal_in_text(
460    file: &Path,
461    content: &str,
462    line_starts: &[usize],
463    literal: &LiteralSearch,
464    max_results: usize,
465    stop_after: usize,
466    total_matches: &AtomicUsize,
467    seen_lines: &mut HashSet<u32>,
468    truncated: &AtomicBool,
469    engine_capped: &AtomicBool,
470    matched_this_file: &mut bool,
471    matches: &mut Vec<GrepMatch>,
472) {
473    let content_bytes = content.as_bytes();
474    let search_content;
475    let haystack = if literal.case_insensitive_ascii {
476        search_content = content_bytes.to_ascii_lowercase();
477        search_content.as_slice()
478    } else {
479        content_bytes
480    };
481    let finder = memchr::memmem::Finder::new(&literal.needle);
482    let mut start = 0usize;
483
484    while let Some(position) = finder.find(&haystack[start..]) {
485        if should_stop_fallback_search(truncated, total_matches, stop_after) {
486            engine_capped.store(true, Ordering::Relaxed);
487            break;
488        }
489
490        let offset = start + position;
491        start = offset + 1;
492        let (line, column, line_text) = line_details(content, line_starts, offset);
493        if !seen_lines.insert(line) {
494            continue;
495        }
496
497        *matched_this_file = true;
498        let match_number = total_matches.fetch_add(1, Ordering::Relaxed) + 1;
499        if match_number > max_results {
500            truncated.store(true, Ordering::Relaxed);
501            break;
502        }
503
504        let end = offset + literal.needle.len();
505        matches.push(GrepMatch {
506            file: file.to_path_buf(),
507            line,
508            column,
509            line_text,
510            match_text: String::from_utf8_lossy(&content_bytes[offset..end]).into_owned(),
511        });
512    }
513}
514
515fn should_stop_fallback_search(
516    truncated: &AtomicBool,
517    total_matches: &AtomicUsize,
518    stop_after: usize,
519) -> bool {
520    truncated.load(Ordering::Relaxed) && total_matches.load(Ordering::Relaxed) >= stop_after
521}
522
523fn ripgrep_grep(
524    search_root: &Path,
525    pattern: &CompiledPattern,
526    include: &[String],
527    exclude: &[String],
528    max_results: usize,
529) -> Option<GrepResult> {
530    let rg = which_rg()?;
531    let mut cmd = Command::new(rg);
532    cmd.args(["-nH", "--hidden", "--no-messages", "--json"]);
533    if pattern.case_insensitive() {
534        cmd.arg("-i");
535    }
536    if pattern.is_literal() {
537        cmd.arg("-F");
538    }
539    for inc in include {
540        cmd.args(["--glob", inc]);
541    }
542    for exc in exclude {
543        let negated = if exc.starts_with('!') {
544            exc.clone()
545        } else {
546            format!("!{exc}")
547        };
548        cmd.args(["--glob", &negated]);
549    }
550    cmd.arg(format!("--regexp={}", pattern.ripgrep_pattern()))
551        .arg(search_root);
552
553    let output = cmd.output().ok()?;
554    let stdout = String::from_utf8_lossy(&output.stdout);
555
556    let mut matches = Vec::new();
557    let mut total_matches = 0usize;
558    let mut files_with_matches_set: HashSet<PathBuf> = HashSet::new();
559    let mut truncated = false;
560    let mut engine_capped = false;
561    let stop_after = max_results.saturating_mul(2);
562
563    for line in stdout.lines() {
564        let parsed: serde_json::Value = serde_json::from_str(line).ok()?;
565        if parsed.get("type").and_then(|value| value.as_str()) != Some("match") {
566            continue;
567        }
568
569        let data = parsed.get("data")?;
570        let file_str = data
571            .get("path")
572            .and_then(|value| value.get("text"))
573            .and_then(|value| value.as_str())?;
574        let line_num = data
575            .get("line_number")
576            .and_then(|value| value.as_u64())
577            .and_then(|value| u32::try_from(value).ok())?;
578        let line_text = data
579            .get("lines")
580            .and_then(|value| value.get("text"))
581            .and_then(|value| value.as_str())?
582            .trim_end_matches(['\r', '\n'])
583            .to_string();
584        let file_path = PathBuf::from(file_str);
585
586        total_matches += 1;
587        files_with_matches_set.insert(file_path.clone());
588
589        if matches.len() < max_results {
590            matches.push(GrepMatch {
591                file: file_path,
592                line: line_num,
593                column: 0,
594                line_text,
595                match_text: String::new(),
596            });
597        } else {
598            truncated = true;
599        }
600        if truncated && total_matches >= stop_after {
601            engine_capped = true;
602            break;
603        }
604    }
605
606    Some(GrepResult {
607        total_matches,
608        matches,
609        files_searched: 0,
610        files_with_matches: files_with_matches_set.len(),
611        index_status: IndexStatus::Fallback,
612        truncated,
613        fully_degraded: true,
614        engine_capped,
615    })
616}
617
618pub(crate) fn ripgrep_glob(
619    search_root: &Path,
620    pattern: &str,
621    max_results: usize,
622) -> Option<Vec<PathBuf>> {
623    let rg = which_rg()?;
624    let mut cmd = Command::new(rg);
625    cmd.args(["--files", "--hidden", "--glob=!.git/*"])
626        .arg(format!("--glob={pattern}"))
627        .arg(search_root);
628
629    let output = cmd.output().ok()?;
630    let stdout = String::from_utf8_lossy(&output.stdout);
631
632    let files: Vec<PathBuf> = stdout
633        .lines()
634        .take(max_results)
635        .map(PathBuf::from)
636        .collect();
637
638    Some(files)
639}
640
641fn which_rg() -> Option<PathBuf> {
642    if let Some(path_var) = std::env::var_os("PATH") {
643        for dir in std::env::split_paths(&path_var) {
644            let candidate = dir.join(if cfg!(windows) { "rg.exe" } else { "rg" });
645            if candidate.is_file() {
646                return Some(candidate);
647            }
648        }
649    }
650
651    None
652}
653
654fn current_index_status(ctx: &AppContext) -> IndexStatus {
655    if ctx
656        .search_index()
657        .borrow()
658        .as_ref()
659        .is_some_and(|index| index.ready)
660    {
661        IndexStatus::Ready
662    } else if ctx.search_index_rx().borrow().is_some() || ctx.search_index().borrow().is_some() {
663        IndexStatus::Building
664    } else {
665        IndexStatus::Fallback
666    }
667}
668
669pub fn line_starts(content: &str) -> Vec<usize> {
670    let mut starts = vec![0usize];
671    for (index, byte) in content.bytes().enumerate() {
672        if byte == b'\n' {
673            starts.push(index + 1);
674        }
675    }
676    starts
677}
678
679pub fn line_details(content: &str, line_starts: &[usize], offset: usize) -> (u32, u32, String) {
680    let line_index = match line_starts.binary_search(&offset) {
681        Ok(index) => index,
682        Err(index) => index.saturating_sub(1),
683    };
684    let line_start = line_starts.get(line_index).copied().unwrap_or(0);
685    let line_end = content[line_start..]
686        .find('\n')
687        .map(|length| line_start + length)
688        .unwrap_or(content.len());
689    let line_text = content[line_start..line_end]
690        .trim_end_matches('\r')
691        .to_string();
692    let column = content[line_start..offset].chars().count() as u32 + 1;
693    (line_index as u32 + 1, column, line_text)
694}
695
696#[cfg(test)]
697mod tests {
698    use super::*;
699
700    fn grep_match(file: &Path, line: u32, column: u32) -> GrepMatch {
701        GrepMatch {
702            file: file.to_path_buf(),
703            line,
704            column,
705            line_text: "needle".to_string(),
706            match_text: "needle".to_string(),
707        }
708    }
709
710    fn result(matches: Vec<GrepMatch>, truncated: bool, status: IndexStatus) -> GrepResult {
711        GrepResult {
712            total_matches: matches.len(),
713            files_searched: matches.len(),
714            files_with_matches: matches.len(),
715            matches,
716            index_status: status,
717            truncated,
718            fully_degraded: false,
719            engine_capped: false,
720        }
721    }
722
723    #[test]
724    fn single_root_uses_requested_max() {
725        let scope = GrepScope {
726            roots: vec![ResolvedRoot {
727                search_root: PathBuf::from("/project"),
728                filter_root: PathBuf::from("/project"),
729                use_index: true,
730                is_external: false,
731            }],
732            multi_root: false,
733            per_root_max: 10,
734        };
735        assert!(!scope.multi_root);
736        assert_eq!(scope.per_root_max, 10);
737    }
738
739    #[test]
740    fn multi_root_uses_double_per_root_max() {
741        let project = tempfile::tempdir().expect("project");
742        let ctx = AppContext::new(
743            Box::new(crate::parser::TreeSitterProvider::new()),
744            crate::config::Config {
745                project_root: Some(project.path().to_path_buf()),
746                ..crate::config::Config::default()
747            },
748        );
749        let left = project.path().join("left");
750        let right = project.path().join("right");
751        std::fs::create_dir_all(&left).expect("left");
752        std::fs::create_dir_all(&right).expect("right");
753        let paths = serde_json::json!([left.display().to_string(), right.display().to_string()]);
754
755        let scope = resolve_grep_scope(&ctx, Some(&paths), 10, "test").expect("scope");
756
757        assert!(scope.multi_root);
758        assert_eq!(scope.per_root_max, 20);
759    }
760
761    #[test]
762    fn filter_root_is_project_for_in_project_and_search_root_for_external_unindexed() {
763        let project = PathBuf::from("/project");
764        let in_project = compute_filter_root(&project, Path::new("/project/src"), true, false);
765        let external = compute_filter_root(&project, Path::new("/tmp/external"), false, true);
766        assert_eq!(in_project, project);
767        assert_eq!(external, PathBuf::from("/tmp/external"));
768    }
769
770    #[test]
771    fn weakest_status_orders_disabled_fallback_building_ready() {
772        assert_eq!(
773            weakest_index_status(IndexStatus::Ready, IndexStatus::Building),
774            IndexStatus::Building
775        );
776        assert_eq!(
777            weakest_index_status(IndexStatus::Building, IndexStatus::Fallback),
778            IndexStatus::Fallback
779        );
780        assert_eq!(
781            weakest_index_status(IndexStatus::Fallback, IndexStatus::Disabled),
782            IndexStatus::Disabled
783        );
784    }
785
786    #[test]
787    fn merge_dedupes_by_canonical_file_line_column() {
788        let temp = tempfile::tempdir().expect("temp");
789        let file = temp.path().join("file.rs");
790        std::fs::write(&file, "needle").expect("write");
791        let symlink = temp.path().join("link.rs");
792        #[cfg(unix)]
793        std::os::unix::fs::symlink(&file, &symlink).expect("symlink");
794        #[cfg(windows)]
795        std::os::windows::fs::symlink_file(&file, &symlink).expect("symlink");
796
797        let merged = merge_grep_results(
798            vec![
799                result(vec![grep_match(&file, 1, 1)], false, IndexStatus::Ready),
800                result(vec![grep_match(&symlink, 1, 1)], false, IndexStatus::Ready),
801            ],
802            temp.path(),
803            10,
804        );
805
806        assert_eq!(merged.matches.len(), 1);
807    }
808
809    #[test]
810    fn merge_truncated_when_child_truncated_or_pre_merge_exceeds_max() {
811        let root = Path::new("/project");
812        let child = merge_grep_results(
813            vec![result(
814                vec![grep_match(Path::new("/project/a.rs"), 1, 1)],
815                true,
816                IndexStatus::Ready,
817            )],
818            root,
819            10,
820        );
821        assert!(child.truncated);
822
823        let many = merge_grep_results(
824            vec![
825                result(
826                    vec![grep_match(Path::new("/project/a.rs"), 1, 1)],
827                    false,
828                    IndexStatus::Ready,
829                ),
830                result(
831                    vec![grep_match(Path::new("/project/b.rs"), 1, 1)],
832                    false,
833                    IndexStatus::Ready,
834                ),
835            ],
836            root,
837            1,
838        );
839        assert!(many.truncated);
840    }
841}