1use crate::constraints::apply_constraints;
9use crate::sort_buffer::sort_with_buffer;
10use crate::types::FileItem;
11use aho_corasick::AhoCorasick;
12use fff_grep::lines::{self, LineStep};
13use fff_grep::{Searcher, SearcherBuilder, Sink, SinkMatch};
14use fff_query_parser::{Constraint, FFFQuery, GrepConfig, QueryParser};
15use grep_matcher::{Match, Matcher, NoCaptures, NoError};
16use rayon::prelude::*;
17use smallvec::SmallVec;
18use std::sync::atomic::{AtomicBool, Ordering};
19use tracing::Level;
20
21pub fn is_definition_line(line: &str) -> bool {
30 let s = line.trim_start().as_bytes();
31 let s = skip_modifiers(s);
32 is_definition_keyword(s)
33}
34
35const MODIFIERS: &[&[u8]] = &[
38 b"pub",
39 b"export",
40 b"default",
41 b"async",
42 b"abstract",
43 b"unsafe",
44 b"static",
45 b"protected",
46 b"private",
47 b"public",
48];
49
50const DEF_KEYWORDS: &[&[u8]] = &[
52 b"struct",
53 b"fn",
54 b"enum",
55 b"trait",
56 b"impl",
57 b"class",
58 b"interface",
59 b"function",
60 b"def",
61 b"func",
62 b"type",
63 b"module",
64 b"object",
65];
66
67fn skip_modifiers(mut s: &[u8]) -> &[u8] {
69 loop {
70 if s.starts_with(b"pub(")
72 && let Some(end) = s.iter().position(|&b| b == b')')
73 {
74 s = skip_ws(&s[end + 1..]);
75 continue;
76 }
77 let mut matched = false;
78 for &kw in MODIFIERS {
79 if s.starts_with(kw) {
80 let rest = &s[kw.len()..];
81 if rest.first().is_some_and(|b| b.is_ascii_whitespace()) {
82 s = skip_ws(rest);
83 matched = true;
84 break;
85 }
86 }
87 }
88 if !matched {
89 return s;
90 }
91 }
92}
93
94fn is_definition_keyword(s: &[u8]) -> bool {
96 for &kw in DEF_KEYWORDS {
97 if s.starts_with(kw) {
98 let after = s.get(kw.len());
99 if after.is_none_or(|b| !b.is_ascii_alphanumeric() && *b != b'_') {
101 return true;
102 }
103 }
104 }
105 false
106}
107
108#[inline]
110fn skip_ws(s: &[u8]) -> &[u8] {
111 let n = s
112 .iter()
113 .position(|b| !b.is_ascii_whitespace())
114 .unwrap_or(s.len());
115 &s[n..]
116}
117
118pub fn is_import_line(line: &str) -> bool {
123 let s = line.trim_start().as_bytes();
124 s.starts_with(b"import ")
125 || s.starts_with(b"import\t")
126 || (s.starts_with(b"from ") && s.get(5).is_some_and(|&b| b == b'\'' || b == b'"'))
127 || s.starts_with(b"use ")
128 || s.starts_with(b"use\t")
129 || starts_with_require(s)
130 || starts_with_include(s)
131}
132
133#[inline]
135fn starts_with_require(s: &[u8]) -> bool {
136 if !s.starts_with(b"require") {
137 return false;
138 }
139 let rest = &s[b"require".len()..];
140 rest.first() == Some(&b'(') || (rest.first() == Some(&b' ') && rest.get(1) == Some(&b'('))
141}
142
143#[inline]
145fn starts_with_include(s: &[u8]) -> bool {
146 if s.first() != Some(&b'#') {
147 return false;
148 }
149 let rest = skip_ws(&s[1..]);
150 rest.starts_with(b"include ") || rest.starts_with(b"include\t")
151}
152
153pub fn has_regex_metacharacters(text: &str) -> bool {
165 regex::escape(text) != text
166}
167
168#[inline]
173fn has_unescaped_newline_escape(text: &str) -> bool {
174 let bytes = text.as_bytes();
175 let mut i = 0;
176 while i < bytes.len().saturating_sub(1) {
177 if bytes[i] == b'\\' {
178 if bytes[i + 1] == b'n' {
179 let mut backslash_count = 1;
181 while backslash_count <= i && bytes[i - backslash_count] == b'\\' {
182 backslash_count += 1;
183 }
184 if backslash_count % 2 == 1 {
186 return true;
187 }
188 }
189 i += 2;
191 } else {
192 i += 1;
193 }
194 }
195 false
196}
197
198fn replace_unescaped_newline_escapes(text: &str) -> String {
203 let bytes = text.as_bytes();
204 let mut result = Vec::with_capacity(bytes.len());
205 let mut i = 0;
206 while i < bytes.len() {
207 if bytes[i] == b'\\' && i + 1 < bytes.len() {
208 if bytes[i + 1] == b'n' {
209 let mut backslash_count = 1;
210 while backslash_count <= i && bytes[i - backslash_count] == b'\\' {
211 backslash_count += 1;
212 }
213 if backslash_count % 2 == 1 {
214 result.push(b'\n');
215 i += 2;
216 continue;
217 }
218 }
219 result.push(bytes[i]);
220 i += 1;
221 } else {
222 result.push(bytes[i]);
223 i += 1;
224 }
225 }
226 String::from_utf8(result).unwrap_or_else(|_| text.to_string())
227}
228
229#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
231pub enum GrepMode {
232 #[default]
236 PlainText,
237 Regex,
241 Fuzzy,
246}
247
248#[derive(Debug, Clone)]
250pub struct GrepMatch {
251 pub file_index: usize,
253 pub line_number: u64,
255 pub col: usize,
257 pub byte_offset: u64,
260 pub line_content: String,
262 pub match_byte_offsets: SmallVec<[(u32, u32); 4]>,
265 pub fuzzy_score: Option<u16>,
267 pub is_definition: bool,
270 pub context_before: Vec<String>,
272 pub context_after: Vec<String>,
274}
275
276#[derive(Debug, Clone, Default)]
278pub struct GrepResult<'a> {
279 pub matches: Vec<GrepMatch>,
280 pub files: Vec<&'a FileItem>,
282 pub total_files_searched: usize,
284 pub total_files: usize,
286 pub filtered_file_count: usize,
288 pub next_file_offset: usize,
291 pub regex_fallback_error: Option<String>,
295}
296
297#[derive(Debug, Clone)]
299pub struct GrepSearchOptions {
300 pub max_file_size: u64,
301 pub max_matches_per_file: usize,
302 pub smart_case: bool,
303 pub file_offset: usize,
307 pub page_limit: usize,
309 pub mode: GrepMode,
311 pub time_budget_ms: u64,
314 pub before_context: usize,
316 pub after_context: usize,
318 pub classify_definitions: bool,
321}
322
323struct RegexMatcher<'r> {
336 regex: &'r regex::bytes::Regex,
337 is_multiline: bool,
338}
339
340impl Matcher for RegexMatcher<'_> {
341 type Captures = NoCaptures;
342 type Error = NoError;
343
344 #[inline]
345 fn find_at(&self, haystack: &[u8], at: usize) -> Result<Option<Match>, NoError> {
346 Ok(self
347 .regex
348 .find_at(haystack, at)
349 .map(|m| Match::new(m.start(), m.end())))
350 }
351
352 #[inline]
353 fn new_captures(&self) -> Result<NoCaptures, NoError> {
354 Ok(NoCaptures::new())
355 }
356
357 #[inline]
358 fn line_terminator(&self) -> Option<grep_matcher::LineTerminator> {
359 if self.is_multiline {
360 None
361 } else {
362 Some(grep_matcher::LineTerminator::byte(b'\n'))
363 }
364 }
365}
366
367struct PlainTextMatcher<'a> {
378 needle: &'a [u8],
381 case_insensitive: bool,
382}
383
384impl Matcher for PlainTextMatcher<'_> {
385 type Captures = NoCaptures;
386 type Error = NoError;
387
388 #[inline]
389 fn find_at(&self, haystack: &[u8], at: usize) -> Result<Option<Match>, NoError> {
390 let hay = &haystack[at..];
391
392 let found = if self.case_insensitive {
393 ascii_case_insensitive_find(hay, self.needle)
396 } else {
397 memchr::memmem::find(hay, self.needle)
398 };
399
400 Ok(found.map(|pos| Match::new(at + pos, at + pos + self.needle.len())))
401 }
402
403 #[inline]
404 fn new_captures(&self) -> Result<NoCaptures, NoError> {
405 Ok(NoCaptures::new())
406 }
407
408 #[inline]
409 fn line_terminator(&self) -> Option<grep_matcher::LineTerminator> {
410 Some(grep_matcher::LineTerminator::byte(b'\n'))
411 }
412}
413
414#[inline]
420fn ascii_case_insensitive_find(haystack: &[u8], needle_lower: &[u8]) -> Option<usize> {
421 let nlen = needle_lower.len();
422 if nlen == 0 {
423 return Some(0);
424 }
425
426 if haystack.len() < nlen {
427 return None;
428 }
429
430 let first_lo = needle_lower[0];
431 let first_hi = first_lo.to_ascii_uppercase();
432
433 if nlen == 1 {
435 return memchr::memchr2(first_lo, first_hi, haystack);
436 }
437
438 let tail = &needle_lower[1..];
439 let end = haystack.len() - nlen;
440
441 for pos in memchr::memchr2_iter(first_lo, first_hi, &haystack[..=end]) {
443 let candidate = unsafe { haystack.get_unchecked(pos + 1..pos + nlen) };
448 if ascii_case_eq(candidate, tail) {
449 return Some(pos);
450 }
451 }
452 None
453}
454
455#[inline]
460fn ascii_case_eq(a: &[u8], b: &[u8]) -> bool {
461 debug_assert_eq!(a.len(), b.len());
462 let len = a.len();
470 let mut i = 0;
471
472 while i + 8 <= len {
474 let va = u64::from_ne_bytes(unsafe { *(a.as_ptr().add(i) as *const [u8; 8]) });
475 let vb = u64::from_ne_bytes(unsafe { *(b.as_ptr().add(i) as *const [u8; 8]) });
476
477 if va != vb {
479 const MASK: u64 = 0x2020_2020_2020_2020;
481 if (va | MASK) != (vb | MASK) {
482 return false;
483 }
484 }
485 i += 8;
486 }
487
488 while i < len {
490 let ha = unsafe { *a.get_unchecked(i) };
491 let hb = unsafe { *b.get_unchecked(i) };
492 if ha != hb && (ha | 0x20) != (hb | 0x20) {
493 return false;
494 }
495 i += 1;
496 }
497
498 true
499}
500
501const MAX_LINE_DISPLAY_LEN: usize = 512;
504
505struct SinkState {
506 file_index: usize,
507 matches: Vec<GrepMatch>,
508 max_matches: usize,
509 before_context: usize,
510 after_context: usize,
511 classify_definitions: bool,
512}
513
514impl SinkState {
515 #[inline]
516 fn prepare_line<'a>(line_bytes: &'a [u8], mat: &SinkMatch<'_>) -> (&'a [u8], u32, u64, u64) {
517 let line_number = mat.line_number().unwrap_or(0);
518 let byte_offset = mat.absolute_byte_offset();
519
520 let trimmed_len = {
522 let mut len = line_bytes.len();
523 while len > 0 && matches!(line_bytes[len - 1], b'\n' | b'\r') {
524 len -= 1;
525 }
526 len
527 };
528 let trimmed_bytes = &line_bytes[..trimmed_len];
529
530 let display_bytes = truncate_display_bytes(trimmed_bytes);
532
533 let display_len = display_bytes.len() as u32;
534 (display_bytes, display_len, line_number, byte_offset)
535 }
536
537 #[inline]
538 #[allow(clippy::too_many_arguments)]
539 fn push_match(
540 &mut self,
541 line_number: u64,
542 col: usize,
543 byte_offset: u64,
544 line_content: String,
545 match_byte_offsets: SmallVec<[(u32, u32); 4]>,
546 context_before: Vec<String>,
547 context_after: Vec<String>,
548 ) {
549 let is_definition = self.classify_definitions && is_definition_line(&line_content);
550 self.matches.push(GrepMatch {
551 file_index: self.file_index,
552 line_number,
553 col,
554 byte_offset,
555 line_content,
556 match_byte_offsets,
557 fuzzy_score: None,
558 is_definition,
559 context_before,
560 context_after,
561 });
562 }
563
564 fn extract_context(&self, mat: &SinkMatch<'_>) -> (Vec<String>, Vec<String>) {
566 if self.before_context == 0 && self.after_context == 0 {
567 return (Vec::new(), Vec::new());
568 }
569
570 let buffer = mat.buffer();
571 let range = mat.bytes_range_in_buffer();
572
573 let mut before = Vec::new();
574 if self.before_context > 0 && range.start > 0 {
575 let mut pos = range.start;
577 let mut lines_found = 0;
578 while lines_found < self.before_context && pos > 0 {
579 pos -= 1;
581 let line_start = match memchr::memrchr(b'\n', &buffer[..pos]) {
583 Some(nl) => nl + 1,
584 None => 0,
585 };
586 let line = &buffer[line_start..pos];
587 let line = if line.last() == Some(&b'\r') {
589 &line[..line.len() - 1]
590 } else {
591 line
592 };
593 let truncated = truncate_display_bytes(line);
594 before.push(String::from_utf8_lossy(truncated).into_owned());
595 pos = line_start;
596 lines_found += 1;
597 }
598 before.reverse();
599 }
600
601 let mut after = Vec::new();
602 if self.after_context > 0 && range.end < buffer.len() {
603 let mut pos = range.end;
604 let mut lines_found = 0;
605 while lines_found < self.after_context && pos < buffer.len() {
606 let line_end = match memchr::memchr(b'\n', &buffer[pos..]) {
608 Some(nl) => pos + nl,
609 None => buffer.len(),
610 };
611 let line = &buffer[pos..line_end];
612 let line = if line.last() == Some(&b'\r') {
614 &line[..line.len() - 1]
615 } else {
616 line
617 };
618 let truncated = truncate_display_bytes(line);
619 after.push(String::from_utf8_lossy(truncated).into_owned());
620 pos = if line_end < buffer.len() {
621 line_end + 1 } else {
623 buffer.len()
624 };
625 lines_found += 1;
626 }
627 }
628
629 (before, after)
630 }
631}
632
633#[inline]
635fn truncate_display_bytes(bytes: &[u8]) -> &[u8] {
636 if bytes.len() <= MAX_LINE_DISPLAY_LEN {
637 bytes
638 } else {
639 let mut end = MAX_LINE_DISPLAY_LEN;
640 while end > 0 && !is_utf8_char_boundary(bytes[end]) {
641 end -= 1;
642 }
643 &bytes[..end]
644 }
645}
646
647struct PlainTextSink<'r> {
654 state: SinkState,
655 finder: &'r memchr::memmem::Finder<'r>,
656 pattern_len: u32,
657 case_insensitive: bool,
658}
659
660impl Sink for PlainTextSink<'_> {
661 type Error = std::io::Error;
662
663 fn matched(&mut self, _searcher: &Searcher, mat: &SinkMatch<'_>) -> Result<bool, Self::Error> {
664 if self.state.max_matches != 0 && self.state.matches.len() >= self.state.max_matches {
665 return Ok(false);
666 }
667
668 let line_bytes = mat.bytes();
669 let (display_bytes, display_len, line_number, byte_offset) =
670 SinkState::prepare_line(line_bytes, mat);
671
672 let line_content = String::from_utf8_lossy(display_bytes).into_owned();
673 let mut match_byte_offsets: SmallVec<[(u32, u32); 4]> = SmallVec::new();
674 let mut col = 0usize;
675 let mut first = true;
676
677 if self.case_insensitive {
678 let mut lowered = [0u8; MAX_LINE_DISPLAY_LEN];
681 let len = display_bytes.len().min(MAX_LINE_DISPLAY_LEN);
682 for (dst, &src) in lowered[..len].iter_mut().zip(display_bytes) {
683 *dst = src.to_ascii_lowercase();
684 }
685
686 let mut start_pos = 0usize;
687 while let Some(pos) = self.finder.find(&lowered[start_pos..len]) {
688 let abs_start = (start_pos + pos) as u32;
689 let abs_end = (abs_start + self.pattern_len).min(display_len);
690 if first {
691 col = abs_start as usize;
692 first = false;
693 }
694 match_byte_offsets.push((abs_start, abs_end));
695 start_pos += pos + 1;
696 }
697 } else {
698 let mut start_pos = 0usize;
699 while let Some(pos) = self.finder.find(&display_bytes[start_pos..]) {
700 let abs_start = (start_pos + pos) as u32;
701 let abs_end = (abs_start + self.pattern_len).min(display_len);
702 if first {
703 col = abs_start as usize;
704 first = false;
705 }
706 match_byte_offsets.push((abs_start, abs_end));
707 start_pos += pos + 1;
708 }
709 }
710
711 let (context_before, context_after) = self.state.extract_context(mat);
712 self.state.push_match(
713 line_number,
714 col,
715 byte_offset,
716 line_content,
717 match_byte_offsets,
718 context_before,
719 context_after,
720 );
721 Ok(true)
722 }
723
724 fn finish(&mut self, _: &Searcher, _: &fff_grep::SinkFinish) -> Result<(), Self::Error> {
725 Ok(())
726 }
727}
728
729struct RegexSink<'r> {
734 state: SinkState,
735 re: &'r regex::bytes::Regex,
736}
737
738impl Sink for RegexSink<'_> {
739 type Error = std::io::Error;
740
741 fn matched(
742 &mut self,
743 _searcher: &Searcher,
744 sink_match: &SinkMatch<'_>,
745 ) -> Result<bool, Self::Error> {
746 if self.state.max_matches != 0 && self.state.matches.len() >= self.state.max_matches {
747 return Ok(false);
748 }
749
750 let line_bytes = sink_match.bytes();
751 let (display_bytes, display_len, line_number, byte_offset) =
752 SinkState::prepare_line(line_bytes, sink_match);
753
754 let line_content = String::from_utf8_lossy(display_bytes).into_owned();
755 let mut match_byte_offsets: SmallVec<[(u32, u32); 4]> = SmallVec::new();
756 let mut col = 0usize;
757 let mut first = true;
758
759 for m in self.re.find_iter(display_bytes) {
760 let abs_start = m.start() as u32;
761 let abs_end = (m.end() as u32).min(display_len);
762 if first {
763 col = abs_start as usize;
764 first = false;
765 }
766 match_byte_offsets.push((abs_start, abs_end));
767 }
768
769 let (context_before, context_after) = self.state.extract_context(sink_match);
770 self.state.push_match(
771 line_number,
772 col,
773 byte_offset,
774 line_content,
775 match_byte_offsets,
776 context_before,
777 context_after,
778 );
779 Ok(true)
780 }
781
782 fn finish(&mut self, _: &Searcher, _: &fff_grep::SinkFinish) -> Result<(), Self::Error> {
783 Ok(())
784 }
785}
786
787struct AhoCorasickMatcher<'a> {
792 ac: &'a AhoCorasick,
793}
794
795impl Matcher for AhoCorasickMatcher<'_> {
796 type Captures = NoCaptures;
797 type Error = NoError;
798
799 #[inline]
800 fn find_at(&self, haystack: &[u8], at: usize) -> std::result::Result<Option<Match>, NoError> {
801 let hay = &haystack[at..];
802 let found: Option<aho_corasick::Match> = self.ac.find(hay);
803 Ok(found.map(|m| Match::new(at + m.start(), at + m.end())))
804 }
805
806 #[inline]
807 fn new_captures(&self) -> Result<NoCaptures, NoError> {
808 Ok(NoCaptures::new())
809 }
810
811 #[inline]
812 fn line_terminator(&self) -> Option<grep_matcher::LineTerminator> {
813 Some(grep_matcher::LineTerminator::byte(b'\n'))
814 }
815}
816
817struct AhoCorasickSink<'a> {
821 state: SinkState,
822 ac: &'a AhoCorasick,
823}
824
825impl Sink for AhoCorasickSink<'_> {
826 type Error = std::io::Error;
827
828 fn matched(&mut self, _searcher: &Searcher, mat: &SinkMatch<'_>) -> Result<bool, Self::Error> {
829 if self.state.max_matches != 0 && self.state.matches.len() >= self.state.max_matches {
830 return Ok(false);
831 }
832
833 let line_bytes = mat.bytes();
834 let (display_bytes, display_len, line_number, byte_offset) =
835 SinkState::prepare_line(line_bytes, mat);
836
837 let line_content = String::from_utf8_lossy(display_bytes).into_owned();
838 let mut match_byte_offsets: SmallVec<[(u32, u32); 4]> = SmallVec::new();
839 let mut col = 0usize;
840 let mut first = true;
841
842 for m in self.ac.find_iter(display_bytes as &[u8]) {
843 let abs_start = m.start() as u32;
844 let abs_end = (m.end() as u32).min(display_len);
845 if first {
846 col = abs_start as usize;
847 first = false;
848 }
849 match_byte_offsets.push((abs_start, abs_end));
850 }
851
852 let (context_before, context_after) = self.state.extract_context(mat);
853 self.state.push_match(
854 line_number,
855 col,
856 byte_offset,
857 line_content,
858 match_byte_offsets,
859 context_before,
860 context_after,
861 );
862 Ok(true)
863 }
864
865 fn finish(&mut self, _: &Searcher, _: &fff_grep::SinkFinish) -> Result<(), Self::Error> {
866 Ok(())
867 }
868}
869
870pub fn multi_grep_search<'a>(
878 files: &'a [FileItem],
879 patterns: &[&str],
880 constraints: &[fff_query_parser::Constraint<'_>],
881 options: &GrepSearchOptions,
882) -> GrepResult<'a> {
883 let total_files = files.len();
884
885 if patterns.is_empty() || patterns.iter().all(|p| p.is_empty()) {
886 return GrepResult {
887 total_files,
888 filtered_file_count: total_files,
889 ..Default::default()
890 };
891 }
892
893 let (mut files_to_search, mut filtered_file_count) =
894 prepare_files_to_search(files, constraints, options);
895
896 if files_to_search.is_empty()
899 && let Some(stripped) = strip_file_path_constraints(constraints)
900 {
901 let (retry_files, retry_count) = prepare_files_to_search(files, &stripped, options);
902 files_to_search = retry_files;
903 filtered_file_count = retry_count;
904 }
905
906 if files_to_search.is_empty() {
907 return GrepResult {
908 total_files,
909 filtered_file_count,
910 ..Default::default()
911 };
912 }
913
914 let case_insensitive = if options.smart_case {
916 !patterns.iter().any(|p| p.chars().any(|c| c.is_uppercase()))
917 } else {
918 false
919 };
920
921 let ac = aho_corasick::AhoCorasickBuilder::new()
922 .ascii_case_insensitive(case_insensitive)
923 .build(patterns)
924 .expect("Aho-Corasick build should not fail for literal patterns");
925
926 let searcher = {
927 let mut b = SearcherBuilder::new();
928 b.line_number(true);
929 b
930 }
931 .build();
932
933 let ac_matcher = AhoCorasickMatcher { ac: &ac };
934 run_file_search(
935 &files_to_search,
936 options,
937 total_files,
938 filtered_file_count,
939 None,
940 |file_bytes: &[u8], max_matches: usize| {
941 let state = SinkState {
942 file_index: 0,
943 matches: Vec::with_capacity(4),
944 max_matches,
945 before_context: options.before_context,
946 after_context: options.after_context,
947 classify_definitions: options.classify_definitions,
948 };
949
950 let mut sink = AhoCorasickSink { state, ac: &ac };
951
952 if let Err(e) = searcher.search_slice(&ac_matcher, file_bytes, &mut sink) {
953 tracing::error!(error = %e, "Grep (aho-corasick multi) search failed");
954 }
955
956 sink.state.matches
957 },
958 )
959}
960
961#[inline]
963const fn is_utf8_char_boundary(b: u8) -> bool {
964 (b as i8) >= -0x40
965}
966
967fn build_regex(pattern: &str, smart_case: bool) -> Result<regex::bytes::Regex, String> {
979 if pattern.is_empty() {
980 return Err("empty pattern".to_string());
981 }
982
983 let regex_pattern = if pattern.contains("\\n") {
984 pattern.replace("\\n", "\n")
985 } else {
986 pattern.to_string()
987 };
988
989 let case_insensitive = if smart_case {
990 !pattern.chars().any(|c| c.is_uppercase())
991 } else {
992 false
993 };
994
995 regex::bytes::RegexBuilder::new(®ex_pattern)
996 .case_insensitive(case_insensitive)
997 .multi_line(true)
998 .unicode(false)
999 .build()
1000 .map_err(|e| e.to_string())
1001}
1002
1003fn char_indices_to_byte_offsets(line: &str, char_indices: &[usize]) -> SmallVec<[(u32, u32); 4]> {
1013 if char_indices.is_empty() {
1014 return SmallVec::new();
1015 }
1016
1017 let char_byte_ranges: Vec<(usize, usize)> = line
1020 .char_indices()
1021 .map(|(byte_pos, ch)| (byte_pos, byte_pos + ch.len_utf8()))
1022 .collect();
1023
1024 let mut result: SmallVec<[(u32, u32); 4]> = SmallVec::with_capacity(char_indices.len());
1026
1027 for &ci in char_indices {
1028 if ci >= char_byte_ranges.len() {
1029 continue; }
1031 let (start, end) = char_byte_ranges[ci];
1032 if let Some(last) = result.last_mut()
1034 && last.1 == start as u32
1035 {
1036 last.1 = end as u32;
1037 continue;
1038 }
1039 result.push((start as u32, end as u32));
1040 }
1041
1042 result
1043}
1044
1045#[tracing::instrument(skip_all, level = Level::DEBUG)]
1046fn run_file_search<'a, F>(
1047 files_to_search: &[&'a FileItem],
1048 options: &GrepSearchOptions,
1049 total_files: usize,
1050 filtered_file_count: usize,
1051 regex_fallback_error: Option<String>,
1052 search_file: F,
1053) -> GrepResult<'a>
1054where
1055 F: Fn(&[u8], usize) -> Vec<GrepMatch> + Sync,
1056{
1057 let time_budget = if options.time_budget_ms > 0 {
1058 Some(std::time::Duration::from_millis(options.time_budget_ms))
1059 } else {
1060 None
1061 };
1062
1063 let search_start = std::time::Instant::now();
1064 let budget_exceeded = AtomicBool::new(false);
1065
1066 let per_file_results: Vec<(usize, &'a FileItem, Vec<GrepMatch>)> = files_to_search
1071 .par_iter()
1072 .enumerate()
1073 .filter_map(|(idx, file)| {
1074 if let Some(budget) = time_budget
1076 && search_start.elapsed() > budget
1077 {
1078 budget_exceeded.store(true, Ordering::Relaxed);
1079 return None;
1080 }
1081
1082 let content = file.get_mmap()?;
1083 let file_matches = search_file(content, options.max_matches_per_file);
1084
1085 if file_matches.is_empty() {
1086 return None;
1087 }
1088
1089 Some((idx, *file, file_matches))
1090 })
1091 .collect();
1092
1093 collect_grep_results(
1094 per_file_results,
1095 files_to_search.len(),
1096 options,
1097 total_files,
1098 filtered_file_count,
1099 regex_fallback_error,
1100 budget_exceeded.load(Ordering::Relaxed),
1101 )
1102}
1103
1104fn collect_grep_results<'a>(
1109 per_file_results: Vec<(usize, &'a FileItem, Vec<GrepMatch>)>,
1110 files_to_search_len: usize,
1111 options: &GrepSearchOptions,
1112 total_files: usize,
1113 filtered_file_count: usize,
1114 regex_fallback_error: Option<String>,
1115 budget_exceeded: bool,
1116) -> GrepResult<'a> {
1117 let page_limit = options.page_limit;
1118
1119 let mut result_files: Vec<&'a FileItem> = Vec::new();
1123 let mut all_matches: Vec<GrepMatch> = Vec::new();
1124 let mut files_consumed: usize = 0;
1131
1132 for (batch_idx, file, file_matches) in per_file_results {
1133 files_consumed = batch_idx + 1;
1136
1137 let file_result_idx = result_files.len();
1138 result_files.push(file);
1139
1140 for mut m in file_matches {
1141 m.file_index = file_result_idx;
1142 all_matches.push(m);
1143 }
1144
1145 if all_matches.len() >= page_limit {
1149 break;
1150 }
1151 }
1152
1153 if result_files.is_empty() {
1155 files_consumed = files_to_search_len;
1156 }
1157
1158 let has_more = budget_exceeded
1159 || (all_matches.len() >= page_limit && files_consumed < files_to_search_len);
1160
1161 let next_file_offset = if has_more {
1162 options.file_offset + files_consumed
1163 } else {
1164 0
1165 };
1166
1167 GrepResult {
1168 matches: all_matches,
1169 files: result_files,
1170 total_files_searched: files_consumed,
1171 total_files,
1172 filtered_file_count,
1173 next_file_offset,
1174 regex_fallback_error,
1175 }
1176}
1177
1178fn prepare_files_to_search<'a>(
1184 files: &'a [FileItem],
1185 constraints: &[fff_query_parser::Constraint<'_>],
1186 options: &GrepSearchOptions,
1187) -> (Vec<&'a FileItem>, usize) {
1188 let prefiltered: Vec<&FileItem> = if constraints.is_empty() {
1189 files
1190 .iter()
1191 .filter(|f| !f.is_binary && f.size > 0 && f.size <= options.max_file_size)
1192 .collect()
1193 } else {
1194 match apply_constraints(files, constraints) {
1195 Some(constrained) => constrained
1196 .into_iter()
1197 .filter(|f| !f.is_binary && f.size > 0 && f.size <= options.max_file_size)
1198 .collect(),
1199 None => files
1200 .iter()
1201 .filter(|f| !f.is_binary && f.size > 0 && f.size <= options.max_file_size)
1202 .collect(),
1203 }
1204 };
1205
1206 let total_count = prefiltered.len();
1207
1208 let mut sorted_files = prefiltered;
1210 sort_with_buffer(&mut sorted_files, |a, b| {
1211 b.total_frecency_score
1212 .cmp(&a.total_frecency_score)
1213 .then(b.modified.cmp(&a.modified))
1214 });
1215
1216 if options.file_offset < total_count {
1217 let sorted_files = sorted_files.split_off(options.file_offset);
1218 (sorted_files, total_count)
1219 } else {
1220 (Vec::new(), total_count)
1221 }
1222}
1223
1224fn fuzzy_grep_search<'a>(
1258 grep_text: &str,
1259 files_to_search: &[&'a FileItem],
1260 options: &GrepSearchOptions,
1261 total_files: usize,
1262 filtered_file_count: usize,
1263 case_insensitive: bool,
1264) -> GrepResult<'a> {
1265 let max_typos = (grep_text.len() / 3).min(2);
1272 let scoring = neo_frizbee::Scoring {
1273 exact_match_bonus: 100,
1280 prefix_bonus: 0,
1283 capitalization_bonus: if case_insensitive { 0 } else { 4 },
1284 ..neo_frizbee::Scoring::default()
1285 };
1286
1287 let matcher = neo_frizbee::Matcher::new(
1288 grep_text,
1289 &neo_frizbee::Config {
1290 max_typos: Some(max_typos as u16),
1292 sort: false,
1293 scoring,
1294 },
1295 );
1296
1297 let perfect_score = (grep_text.len() as u16) * 16;
1301 let min_score = (perfect_score * 50) / 100;
1302
1303 let max_match_span = grep_text.len() * 2;
1311 let needle_len = grep_text.len();
1312
1313 let max_gaps = (needle_len / 4).max(1);
1315
1316 let needle_bytes = grep_text.as_bytes();
1320 let mut unique_needle_chars: Vec<u8> = Vec::new();
1321 for &b in needle_bytes {
1322 let lo = b.to_ascii_lowercase();
1323 let hi = b.to_ascii_uppercase();
1324 if !unique_needle_chars.contains(&lo) {
1325 unique_needle_chars.push(lo);
1326 }
1327 if lo != hi && !unique_needle_chars.contains(&hi) {
1328 unique_needle_chars.push(hi);
1329 }
1330 }
1331 let unique_count = {
1334 let mut seen = [false; 256];
1335 for &b in needle_bytes {
1336 seen[b.to_ascii_lowercase() as usize] = true;
1337 }
1338 seen.iter().filter(|&&v| v).count()
1339 };
1340 let min_chars_required = unique_count.saturating_sub(max_typos);
1341
1342 let time_budget = if options.time_budget_ms > 0 {
1343 Some(std::time::Duration::from_millis(options.time_budget_ms))
1344 } else {
1345 None
1346 };
1347 let search_start = std::time::Instant::now();
1348 let budget_exceeded = AtomicBool::new(false);
1349 let max_matches_per_file = options.max_matches_per_file;
1350
1351 let per_file_results: Vec<(usize, &'a FileItem, Vec<GrepMatch>)> = files_to_search
1355 .par_iter()
1356 .enumerate()
1357 .map_init(
1358 || matcher.clone(),
1359 |matcher, (idx, file)| {
1360 if let Some(budget) = time_budget
1361 && search_start.elapsed() > budget
1362 {
1363 budget_exceeded.store(true, Ordering::Relaxed);
1364 return None;
1365 }
1366
1367 let file_bytes = file.get_mmap()?;
1368
1369 if min_chars_required > 0 {
1372 let mut chars_found = 0usize;
1373 for &ch in &unique_needle_chars {
1374 if memchr::memchr(ch, file_bytes).is_some() {
1375 chars_found += 1;
1376 if chars_found >= min_chars_required {
1377 break;
1378 }
1379 }
1380 }
1381 if chars_found < min_chars_required {
1382 return None;
1383 }
1384 }
1385
1386 let file_is_utf8 = std::str::from_utf8(file_bytes).is_ok();
1390
1391 let mut stepper = LineStep::new(b'\n', 0, file_bytes.len());
1393 let estimated_lines = (file_bytes.len() / 40).max(64);
1394 let mut file_lines: Vec<&str> = Vec::with_capacity(estimated_lines);
1395 let mut line_meta: Vec<(u64, u64)> = Vec::with_capacity(estimated_lines);
1396 let line_term_lf = grep_matcher::LineTerminator::byte(b'\n');
1397 let line_term_cr = grep_matcher::LineTerminator::byte(b'\r');
1398
1399 let mut line_number: u64 = 1;
1400 while let Some(line_match) = stepper.next_match(file_bytes) {
1401 let byte_offset = line_match.start() as u64;
1402
1403 let trimmed = lines::without_terminator(
1405 lines::without_terminator(&file_bytes[line_match], line_term_lf),
1406 line_term_cr,
1407 );
1408
1409 if !trimmed.is_empty() {
1410 let line_str = if file_is_utf8 {
1414 unsafe { std::str::from_utf8_unchecked(trimmed) }
1415 } else if let Ok(s) = std::str::from_utf8(trimmed) {
1416 s
1417 } else {
1418 line_number += 1;
1419 continue;
1420 };
1421 file_lines.push(line_str);
1422 line_meta.push((line_number, byte_offset));
1423 }
1424
1425 line_number += 1;
1426 }
1427
1428 if file_lines.is_empty() {
1429 return None;
1430 }
1431
1432 let matches_with_indices = matcher.match_list_indices(&file_lines);
1434 let mut file_matches: Vec<GrepMatch> = Vec::new();
1435
1436 for mut match_indices in matches_with_indices {
1437 if match_indices.score < min_score {
1438 continue;
1439 }
1440
1441 let idx = match_indices.index as usize;
1442 let raw_line = file_lines[idx];
1443
1444 let truncated = truncate_display_bytes(raw_line.as_bytes());
1445 let display_line = if truncated.len() < raw_line.len() {
1446 &raw_line[..truncated.len()]
1448 } else {
1449 raw_line
1450 };
1451
1452 if display_line.len() < raw_line.len() {
1454 let Some(re_indices) = matcher
1455 .match_list_indices(&[display_line])
1456 .into_iter()
1457 .next()
1458 else {
1459 continue;
1460 };
1461 match_indices = re_indices;
1462 }
1463
1464 match_indices.indices.sort_unstable();
1466
1467 let min_matched = needle_len.saturating_sub(1).max(1);
1472 if match_indices.indices.len() < min_matched {
1473 continue;
1474 }
1475
1476 let indices = &match_indices.indices;
1477
1478 if let (Some(&first), Some(&last)) = (indices.first(), indices.last()) {
1479 let span = last - first + 1;
1481 if span > max_match_span {
1482 continue;
1483 }
1484
1485 let density = (indices.len() * 100) / span;
1489 let min_density = if indices.len() >= needle_len {
1490 50 } else {
1492 70 };
1494 if density < min_density {
1495 continue;
1496 }
1497
1498 let gap_count = indices.windows(2).filter(|w| w[1] != w[0] + 1).count();
1500 if gap_count > max_gaps {
1501 continue;
1502 }
1503 }
1504
1505 let (ln, bo) = line_meta[idx];
1506 let match_byte_offsets =
1507 char_indices_to_byte_offsets(display_line, &match_indices.indices);
1508 let col = match_byte_offsets
1509 .first()
1510 .map(|r| r.0 as usize)
1511 .unwrap_or(0);
1512
1513 file_matches.push(GrepMatch {
1514 file_index: 0,
1515 line_number: ln,
1516 col,
1517 byte_offset: bo,
1518 is_definition: options.classify_definitions
1519 && is_definition_line(display_line),
1520 line_content: display_line.to_string(),
1521 match_byte_offsets,
1522 fuzzy_score: Some(match_indices.score),
1523 context_before: Vec::new(),
1524 context_after: Vec::new(),
1525 });
1526
1527 if max_matches_per_file != 0 && file_matches.len() >= max_matches_per_file {
1528 break;
1529 }
1530 }
1531
1532 if file_matches.is_empty() {
1533 return None;
1534 }
1535
1536 Some((idx, *file, file_matches))
1537 },
1538 )
1539 .flatten()
1540 .collect();
1541
1542 collect_grep_results(
1543 per_file_results,
1544 files_to_search.len(),
1545 options,
1546 total_files,
1547 filtered_file_count,
1548 None,
1549 budget_exceeded.load(Ordering::Relaxed),
1550 )
1551}
1552
1553pub fn grep_search<'a>(
1558 files: &'a [FileItem],
1559 query: &FFFQuery<'_>,
1560 options: &GrepSearchOptions,
1561) -> GrepResult<'a> {
1562 let total_files = files.len();
1563
1564 let constraints_from_query = &query.constraints[..];
1570
1571 let grep_text = if !matches!(query.fuzzy_query, fff_query_parser::FuzzyQuery::Empty) {
1572 query.grep_text()
1573 } else {
1574 let t = query.raw_query.trim();
1576 if t.starts_with('\\') && t.len() > 1 {
1577 let suffix = &t[1..];
1578 let parser = QueryParser::new(GrepConfig);
1579 if !parser.parse(suffix).constraints.is_empty() {
1580 suffix.to_string()
1581 } else {
1582 t.to_string()
1583 }
1584 } else {
1585 t.to_string()
1586 }
1587 };
1588
1589 if grep_text.is_empty() {
1590 return GrepResult {
1591 total_files,
1592 filtered_file_count: total_files,
1593 next_file_offset: 0,
1594 matches: Vec::with_capacity(4),
1595 files: Vec::new(),
1596 ..Default::default()
1597 };
1598 }
1599
1600 let (mut files_to_search, mut filtered_file_count) =
1602 prepare_files_to_search(files, constraints_from_query, options);
1603
1604 if files_to_search.is_empty()
1610 && let Some(stripped) = strip_file_path_constraints(constraints_from_query)
1611 {
1612 let (retry_files, retry_count) = prepare_files_to_search(files, &stripped, options);
1613 files_to_search = retry_files;
1614 filtered_file_count = retry_count;
1615 }
1616
1617 if files_to_search.is_empty() {
1618 return GrepResult {
1619 total_files,
1620 filtered_file_count,
1621 next_file_offset: 0,
1622 ..Default::default()
1623 };
1624 }
1625
1626 let case_insensitive = if options.smart_case {
1627 !grep_text.chars().any(|c| c.is_uppercase())
1628 } else {
1629 false
1630 };
1631
1632 let mut regex_fallback_error: Option<String> = None;
1633 let regex = match options.mode {
1634 GrepMode::PlainText => None,
1635 GrepMode::Fuzzy => {
1636 return fuzzy_grep_search(
1637 &grep_text,
1638 &files_to_search,
1639 options,
1640 total_files,
1641 filtered_file_count,
1642 case_insensitive,
1643 );
1644 }
1645 GrepMode::Regex => build_regex(&grep_text, options.smart_case)
1646 .inspect_err(|err| {
1647 tracing::warn!("Regex compilation failed for {}. Error {}", grep_text, err);
1648
1649 regex_fallback_error = Some(err.to_string());
1650 })
1651 .ok(),
1652 };
1653
1654 let is_multiline = has_unescaped_newline_escape(&grep_text);
1655
1656 let effective_pattern = if is_multiline {
1657 replace_unescaped_newline_escapes(&grep_text)
1658 } else {
1659 grep_text.to_string()
1660 };
1661
1662 let finder_pattern: Vec<u8> = if case_insensitive {
1665 effective_pattern.as_bytes().to_ascii_lowercase()
1666 } else {
1667 effective_pattern.as_bytes().to_vec()
1668 };
1669 let finder = memchr::memmem::Finder::new(&finder_pattern);
1670 let pattern_len = finder_pattern.len() as u32;
1671
1672 let plain_matcher = PlainTextMatcher {
1675 needle: &finder_pattern,
1676 case_insensitive,
1677 };
1678
1679 let searcher = {
1680 let mut b = SearcherBuilder::new();
1681 b.line_number(true).multi_line(is_multiline);
1682 b
1683 }
1684 .build();
1685
1686 run_file_search(
1689 &files_to_search,
1690 options,
1691 total_files,
1692 filtered_file_count,
1693 regex_fallback_error,
1694 |file_bytes: &[u8], max_matches: usize| {
1695 let state = SinkState {
1696 file_index: 0, matches: Vec::with_capacity(4),
1698 max_matches,
1699 before_context: options.before_context,
1700 after_context: options.after_context,
1701 classify_definitions: options.classify_definitions,
1702 };
1703
1704 match regex {
1705 Some(ref re) => {
1706 let regex_matcher = RegexMatcher {
1707 regex: re,
1708 is_multiline,
1709 };
1710 let mut sink = RegexSink { state, re };
1711 if let Err(e) = searcher.search_slice(®ex_matcher, file_bytes, &mut sink) {
1712 tracing::error!(error = %e, "Grep (regex) search failed");
1713 }
1714 sink.state.matches
1715 }
1716 None => {
1717 let mut sink = PlainTextSink {
1718 state,
1719 finder: &finder,
1720 pattern_len,
1721 case_insensitive,
1722 };
1723 if let Err(e) = searcher.search_slice(&plain_matcher, file_bytes, &mut sink) {
1724 tracing::error!(error = %e, "Grep (plain text) search failed");
1725 }
1726 sink.state.matches
1727 }
1728 }
1729 },
1730 )
1731}
1732
1733pub fn parse_grep_query(query: &str) -> FFFQuery<'_> {
1735 let parser = QueryParser::new(GrepConfig);
1736 parser.parse(query)
1737}
1738
1739fn strip_file_path_constraints<'a>(
1740 constraints: &[Constraint<'a>],
1741) -> Option<fff_query_parser::ConstraintVec<'a>> {
1742 if !constraints
1743 .iter()
1744 .any(|c| matches!(c, Constraint::FilePath(_)))
1745 {
1746 return None;
1747 }
1748
1749 let filtered: fff_query_parser::ConstraintVec<'a> = constraints
1750 .iter()
1751 .filter(|c| !matches!(c, Constraint::FilePath(_)))
1752 .cloned()
1753 .collect();
1754
1755 Some(filtered)
1756}
1757
1758#[cfg(test)]
1759mod tests {
1760 use super::*;
1761
1762 #[test]
1763 fn test_unescaped_newline_detection() {
1764 assert!(has_unescaped_newline_escape("foo\\nbar"));
1766 assert!(!has_unescaped_newline_escape("foo\\\\nvim-data"));
1769 assert!(!has_unescaped_newline_escape(
1772 r#"format!("{}\\AppData\\Local\\nvim-data","#
1773 ));
1774 assert!(!has_unescaped_newline_escape("hello world"));
1776 assert!(!has_unescaped_newline_escape("foo\\\\\\\\nbar"));
1778 assert!(has_unescaped_newline_escape("foo\\\\\\nbar"));
1780 }
1781
1782 #[test]
1783 fn test_replace_unescaped_newline() {
1784 assert_eq!(replace_unescaped_newline_escapes("foo\\nbar"), "foo\nbar");
1786 assert_eq!(
1788 replace_unescaped_newline_escapes("foo\\\\nvim"),
1789 "foo\\\\nvim"
1790 );
1791 }
1792
1793 #[test]
1794 fn test_fuzzy_typo_scoring() {
1795 let needle = "schema";
1797 let max_typos = (needle.len() / 3).min(2); let config = neo_frizbee::Config {
1799 max_typos: Some(max_typos as u16),
1800 sort: false,
1801 scoring: neo_frizbee::Scoring {
1802 exact_match_bonus: 100,
1803 ..neo_frizbee::Scoring::default()
1804 },
1805 };
1806 let min_matched = needle.len().saturating_sub(1).max(1); let max_match_span = needle.len() + 4; let passes = |n: &str, h: &str| -> bool {
1811 let Some(mut mi) = neo_frizbee::match_list_indices(n, &[h], &config)
1812 .into_iter()
1813 .next()
1814 else {
1815 return false;
1816 };
1817 mi.indices.sort_unstable();
1819 if mi.indices.len() < min_matched {
1820 return false;
1821 }
1822 if let (Some(&first), Some(&last)) = (mi.indices.first(), mi.indices.last()) {
1823 let span = last - first + 1;
1824 if span > max_match_span {
1825 return false;
1826 }
1827 let density = (mi.indices.len() * 100) / span;
1828 if density < 70 {
1829 return false;
1830 }
1831 }
1832 true
1833 };
1834
1835 assert!(passes("schema", "schema"));
1837 assert!(passes("schema", " schema: String,"));
1839 assert!(passes("schema", "pub fn validate_schema() {}"));
1841 assert!(passes("shcema", "schema"));
1843 assert!(!passes("schema", "it has ema in it"));
1845 assert!(!passes("schema", "hello world foo bar"));
1847 }
1848
1849 #[test]
1850 fn test_multi_grep_search() {
1851 use crate::types::FileItem;
1852 use std::io::Write;
1853
1854 let dir = tempfile::tempdir().unwrap();
1856
1857 let file1_path = dir.path().join("grep.rs");
1859 {
1860 let mut f = std::fs::File::create(&file1_path).unwrap();
1861 writeln!(f, "pub enum GrepMode {{").unwrap();
1862 writeln!(f, " PlainText,").unwrap();
1863 writeln!(f, " Regex,").unwrap();
1864 writeln!(f, "}}").unwrap();
1865 writeln!(f, "pub struct GrepMatch {{").unwrap();
1866 writeln!(f, " pub line_number: u64,").unwrap();
1867 writeln!(f, "}}").unwrap();
1868 }
1869
1870 let file2_path = dir.path().join("matcher.rs");
1872 {
1873 let mut f = std::fs::File::create(&file2_path).unwrap();
1874 writeln!(f, "struct PlainTextMatcher {{").unwrap();
1875 writeln!(f, " needle: Vec<u8>,").unwrap();
1876 writeln!(f, "}}").unwrap();
1877 }
1878
1879 let file3_path = dir.path().join("other.rs");
1881 {
1882 let mut f = std::fs::File::create(&file3_path).unwrap();
1883 writeln!(f, "fn main() {{").unwrap();
1884 writeln!(f, " println!(\"hello\");").unwrap();
1885 writeln!(f, "}}").unwrap();
1886 }
1887
1888 let meta1 = std::fs::metadata(&file1_path).unwrap();
1889 let meta2 = std::fs::metadata(&file2_path).unwrap();
1890 let meta3 = std::fs::metadata(&file3_path).unwrap();
1891
1892 let files = vec![
1893 FileItem::new_raw(
1894 file1_path,
1895 "grep.rs".to_string(),
1896 "grep.rs".to_string(),
1897 meta1.len(),
1898 0,
1899 None,
1900 false,
1901 ),
1902 FileItem::new_raw(
1903 file2_path,
1904 "matcher.rs".to_string(),
1905 "matcher.rs".to_string(),
1906 meta2.len(),
1907 0,
1908 None,
1909 false,
1910 ),
1911 FileItem::new_raw(
1912 file3_path,
1913 "other.rs".to_string(),
1914 "other.rs".to_string(),
1915 meta3.len(),
1916 0,
1917 None,
1918 false,
1919 ),
1920 ];
1921
1922 let options = super::GrepSearchOptions {
1923 max_file_size: 10 * 1024 * 1024,
1924 max_matches_per_file: 0,
1925 smart_case: true,
1926 file_offset: 0,
1927 page_limit: 100,
1928 mode: super::GrepMode::PlainText,
1929 time_budget_ms: 0,
1930 before_context: 0,
1931 after_context: 0,
1932 classify_definitions: false,
1933 };
1934
1935 let result = super::multi_grep_search(
1937 &files,
1938 &["GrepMode", "GrepMatch", "PlainTextMatcher"],
1939 &[],
1940 &options,
1941 );
1942
1943 assert!(
1945 result.matches.len() >= 3,
1946 "Expected at least 3 matches, got {}",
1947 result.matches.len()
1948 );
1949
1950 let has_grep_mode = result
1952 .matches
1953 .iter()
1954 .any(|m| m.line_content.contains("GrepMode"));
1955 let has_grep_match = result
1956 .matches
1957 .iter()
1958 .any(|m| m.line_content.contains("GrepMatch"));
1959 let has_plain_text_matcher = result
1960 .matches
1961 .iter()
1962 .any(|m| m.line_content.contains("PlainTextMatcher"));
1963
1964 assert!(has_grep_mode, "Should find GrepMode");
1965 assert!(has_grep_match, "Should find GrepMatch");
1966 assert!(has_plain_text_matcher, "Should find PlainTextMatcher");
1967
1968 assert_eq!(result.files.len(), 2, "Should match exactly 2 files");
1970
1971 let result2 = super::multi_grep_search(&files, &["PlainTextMatcher"], &[], &options);
1973 assert_eq!(
1974 result2.matches.len(),
1975 1,
1976 "Single pattern should find 1 match"
1977 );
1978
1979 let result3 = super::multi_grep_search(&files, &[], &[], &options);
1981 assert_eq!(
1982 result3.matches.len(),
1983 0,
1984 "Empty patterns should find nothing"
1985 );
1986 }
1987}