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 ¶ms.include,
232 ¶ms.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 ¶ms.include,
242 ¶ms.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 ¶ms.include,
259 ¶ms.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}