Skip to main content

sift_core/search/query/
mod.rs

1use std::sync::Mutex;
2use std::time::Instant;
3#[cfg(test)]
4use std::{
5    io,
6    path::{Path, PathBuf},
7};
8
9#[cfg(test)]
10use grep_matcher::Matcher;
11use grep_regex::RegexMatcher;
12use grep_searcher::Searcher;
13use once_cell::sync::OnceCell;
14
15#[cfg(test)]
16use crate::candidate::Candidate;
17use crate::query::QueryFlags;
18use crate::query::QuerySpec;
19use crate::search::SearchError;
20use crate::search::SearchOutcome;
21use crate::search::emit::format::sum_candidate_file_bytes;
22use crate::search::emit::stats::{SearchStats, TextStatsCounters};
23#[cfg(test)]
24use crate::search::filter::CandidateFilter;
25#[cfg(test)]
26use crate::search::filter::config::{
27    CandidateFilterConfig, HiddenMode, IgnoreConfig, VisibilityConfig,
28};
29use crate::search::options::SearchOptions;
30use crate::search::output::SearchOutputFormat;
31#[cfg(test)]
32use crate::search::output::mode::MatchEmissionMode;
33use crate::search::output::mode::SearchMode;
34use crate::search::request::SearchExecution;
35
36pub mod matcher;
37
38type SearcherCacheEntry = ((bool, Option<usize>, usize, usize), Searcher);
39
40#[derive(Debug, Clone, PartialEq, Eq)]
41pub struct Match {
42    pub file: std::path::PathBuf,
43    pub line: usize,
44    pub text: String,
45}
46
47#[derive(Debug)]
48pub struct SearchQuery {
49    pub patterns: Vec<String>,
50    pub opts: SearchOptions,
51    pub matcher: OnceCell<RegexMatcher>,
52    pub searcher_cache: Mutex<Option<SearcherCacheEntry>>,
53}
54
55impl SearchQuery {
56    /// Creates a new search query from patterns and options.
57    ///
58    /// # Errors
59    ///
60    /// Returns `SearchError::EmptyPatterns` if the pattern list is empty.
61    pub fn new(patterns: &[String], opts: SearchOptions) -> Result<Self, SearchError> {
62        if patterns.is_empty() {
63            return Err(SearchError::EmptyPatterns);
64        }
65        Ok(Self {
66            patterns: patterns.to_vec(),
67            opts,
68            matcher: OnceCell::new(),
69            searcher_cache: Mutex::new(None),
70        })
71    }
72
73    #[must_use]
74    pub fn patterns(&self) -> &[String] {
75        &self.patterns
76    }
77
78    pub(crate) fn spec(&self) -> QuerySpec<'_> {
79        self.build_query_spec()
80    }
81
82    pub(crate) fn search(&self, execution: &SearchExecution<'_>) -> crate::Result<SearchOutcome> {
83        if self.opts.max_results == Some(0) {
84            return Err(SearchError::InvalidMaxCount.into());
85        }
86
87        let output = execution.output;
88        let candidates = &execution.candidates;
89
90        if candidates.is_empty() {
91            return Ok(SearchOutcome {
92                matched: false,
93                stats: execution.collect_stats.then_some(SearchStats::default()),
94            });
95        }
96
97        let search_start = Instant::now();
98        let matcher = self.matcher.get_or_try_init(|| self.build_matcher())?;
99
100        let (did_match, stats) = match output.format {
101            SearchOutputFormat::Json => match output.mode {
102                SearchMode::Standard | SearchMode::OnlyMatching => {
103                    let mut stats = execution.collect_stats.then_some(SearchStats::default());
104                    let scan = crate::search::scan::json::JsonScan::new(
105                        self,
106                        matcher,
107                        output,
108                        search_start,
109                    );
110                    let json_matched = scan.run(candidates, stats.as_mut())?;
111                    (json_matched, stats)
112                }
113                _ => return Err(SearchError::JsonOutputIncompatibleMode.into()),
114            },
115            SearchOutputFormat::Text => self.run_text_output(
116                candidates,
117                matcher,
118                output,
119                execution.separators,
120                search_start,
121                execution.collect_stats,
122            )?,
123        };
124
125        Ok(SearchOutcome {
126            matched: did_match,
127            stats,
128        })
129    }
130
131    fn build_query_spec(&self) -> QuerySpec<'_> {
132        let mut flags = QueryFlags::empty();
133        if self.opts.fixed_strings() {
134            flags |= QueryFlags::FIXED_STRINGS;
135        }
136        if self.opts.case_insensitive() {
137            flags |= QueryFlags::CASE_INSENSITIVE;
138        }
139        if self.opts.word_regexp() {
140            flags |= QueryFlags::WORD_REGEXP;
141        }
142        if self.opts.line_regexp() {
143            flags |= QueryFlags::LINE_REGEXP;
144        }
145        if self.opts.invert_match() {
146            flags |= QueryFlags::INVERT_MATCH;
147        }
148        QuerySpec {
149            patterns: &self.patterns,
150            flags,
151        }
152    }
153
154    fn run_text_output(
155        &self,
156        candidates: &[crate::Candidate],
157        matcher: &RegexMatcher,
158        output: crate::search::output::SearchOutput,
159        separators: &crate::search::output::style::SearchSeparators,
160        search_start: Instant,
161        collect_stats: bool,
162    ) -> crate::Result<(bool, Option<SearchStats>)> {
163        let counters = TextStatsCounters::new(collect_stats);
164
165        let did_match = match output.mode {
166            SearchMode::Standard | SearchMode::OnlyMatching => {
167                let scan = crate::search::scan::standard::StandardScan::new(
168                    self, matcher, output, separators, &counters,
169                );
170                scan.run(candidates)?
171            }
172            SearchMode::Count
173            | SearchMode::CountMatches
174            | SearchMode::FilesWithMatches
175            | SearchMode::FilesWithoutMatch => {
176                let scan = crate::search::scan::summary::SummaryScan::new(
177                    self, matcher, output, &counters,
178                );
179                scan.run(candidates)?
180            }
181        };
182
183        let stats = counters.finish(
184            candidates.len(),
185            sum_candidate_file_bytes(candidates),
186            search_start.elapsed(),
187        );
188
189        Ok((did_match, stats))
190    }
191
192    #[cfg(test)]
193    pub(crate) fn collect_index_matches(
194        &self,
195        index: &crate::index::Index,
196    ) -> crate::Result<Vec<crate::search::Match>> {
197        let config = CandidateFilterConfig {
198            visibility: VisibilityConfig {
199                hidden: HiddenMode::Include,
200                ignore: IgnoreConfig::default(),
201            },
202            ..CandidateFilterConfig::default()
203        };
204        let filter = CandidateFilter::new(&config, index.root())?;
205        let spec = self.build_query_spec();
206        let candidates = index.candidates(&spec).unwrap_or_default();
207        self.collect_index_candidate_paths(&filter, &candidates)
208    }
209
210    #[cfg(test)]
211    pub(crate) fn collect_walk_matches(
212        &self,
213        root: &Path,
214    ) -> crate::Result<Vec<crate::search::Match>> {
215        let root = root.canonicalize()?;
216        let mut candidates = Vec::new();
217        let walker = ignore::WalkBuilder::new(&root)
218            .follow_links(false)
219            .hidden(false)
220            .parents(false)
221            .ignore(false)
222            .git_global(false)
223            .git_ignore(false)
224            .git_exclude(false)
225            .require_git(false)
226            .build();
227        for entry in walker {
228            let entry = entry.map_err(crate::Error::Ignore)?;
229            if entry.file_type().is_some_and(|ft| ft.is_file()) {
230                let path = entry.path();
231                if path.components().any(|c| c.as_os_str() == ".sift") {
232                    continue;
233                }
234                candidates.push(path.to_path_buf());
235            }
236        }
237        self.collect_walk_candidates(&candidates)
238    }
239
240    #[cfg(test)]
241    fn collect_index_candidate_paths(
242        &self,
243        filter: &CandidateFilter,
244        candidates: &[Candidate],
245    ) -> crate::Result<Vec<crate::search::Match>> {
246        let matcher = self.matcher.get_or_try_init(|| self.build_matcher())?;
247        let mut out = Vec::new();
248        let mut searcher = self.build_searcher(true, None, true);
249        for candidate in candidates {
250            if !candidate.matches(filter) {
251                continue;
252            }
253            let mut sink = CollectSink::new(
254                candidate.abs_path().to_path_buf(),
255                if self.opts.only_matching() {
256                    MatchEmissionMode::OnlyMatching
257                } else {
258                    MatchEmissionMode::Lines
259                },
260                matcher.clone(),
261            );
262            let _ = searcher.search_path(matcher, candidate.abs_path(), &mut sink);
263            out.extend(sink.into_matches());
264        }
265        Ok(out)
266    }
267
268    #[cfg(test)]
269    fn collect_walk_candidates(
270        &self,
271        candidates: &[PathBuf],
272    ) -> crate::Result<Vec<crate::search::Match>> {
273        let matcher = self.matcher.get_or_try_init(|| self.build_matcher())?;
274        let mut out = Vec::new();
275        let mut searcher = self.build_searcher(true, None, true);
276        for candidate in candidates {
277            let mut sink = CollectSink::new(
278                candidate.clone(),
279                if self.opts.only_matching() {
280                    MatchEmissionMode::OnlyMatching
281                } else {
282                    MatchEmissionMode::Lines
283                },
284                matcher.clone(),
285            );
286            let _ = searcher.search_path(matcher, candidate, &mut sink);
287            out.extend(sink.into_matches());
288        }
289        Ok(out)
290    }
291}
292
293#[cfg(test)]
294struct CollectSink {
295    path: PathBuf,
296    emission: MatchEmissionMode,
297    matcher: RegexMatcher,
298    matches: Vec<crate::search::Match>,
299}
300
301#[cfg(test)]
302impl CollectSink {
303    fn new(path: PathBuf, emission: MatchEmissionMode, matcher: RegexMatcher) -> Self {
304        Self {
305            path,
306            emission,
307            matcher,
308            matches: Vec::new(),
309        }
310    }
311
312    fn into_matches(self) -> Vec<crate::search::Match> {
313        self.matches
314    }
315}
316
317#[cfg(test)]
318impl grep_searcher::Sink for CollectSink {
319    type Error = io::Error;
320
321    fn matched(
322        &mut self,
323        _: &grep_searcher::Searcher,
324        mat: &grep_searcher::SinkMatch<'_>,
325    ) -> Result<bool, Self::Error> {
326        let line = usize::try_from(mat.line_number().unwrap_or(0)).unwrap_or(0);
327        let line_bytes = mat.bytes();
328        if matches!(self.emission, MatchEmissionMode::OnlyMatching) {
329            let _ = self
330                .matcher
331                .find_iter(line_bytes, |m: grep_matcher::Match| {
332                    self.matches.push(crate::search::Match {
333                        file: self.path.clone(),
334                        line,
335                        text: String::from_utf8_lossy(&line_bytes[m.start()..m.end()]).into_owned(),
336                    });
337                    true
338                });
339        } else {
340            self.matches.push(crate::search::Match {
341                file: self.path.clone(),
342                line,
343                text: String::from_utf8_lossy(line_bytes).into_owned(),
344            });
345        }
346        Ok(true)
347    }
348}
349
350#[cfg(test)]
351mod tests {
352    use super::*;
353    use crate::search::options::SearchMatchFlags;
354
355    #[test]
356    fn case_mode_insensitive_returns_true() {
357        assert!(crate::search::options::CaseMode::Insensitive.is_case_insensitive());
358    }
359
360    #[test]
361    fn case_mode_sensitive_returns_false() {
362        assert!(!crate::search::options::CaseMode::Sensitive.is_case_insensitive());
363    }
364
365    #[test]
366    fn case_mode_smart_returns_false() {
367        assert!(!crate::search::options::CaseMode::Smart.is_case_insensitive());
368    }
369
370    #[test]
371    fn search_options_defaults() {
372        let opts = SearchOptions::default();
373        assert!(!opts.case_insensitive());
374        assert!(!opts.invert_match());
375        assert!(!opts.fixed_strings());
376        assert!(!opts.word_regexp());
377        assert!(!opts.line_regexp());
378        assert!(!opts.only_matching());
379        assert!(!opts.multiline());
380        assert!(!opts.multiline_dotall());
381        assert!(!opts.crlf());
382        assert!(!opts.precludes_trigram_index());
383        assert_eq!(opts.max_results, None);
384        assert_eq!(opts.before_context, 0);
385        assert_eq!(opts.after_context, 0);
386        assert_eq!(opts.binary_mode, crate::search::options::BinaryMode::Quit);
387        assert!(opts.unicode);
388    }
389
390    #[test]
391    fn search_options_precludes_trigram_index_only_for_invert_match() {
392        let mut opts = SearchOptions::default();
393        assert!(!opts.precludes_trigram_index());
394
395        opts.flags |= SearchMatchFlags::INVERT_MATCH;
396        assert!(opts.precludes_trigram_index());
397    }
398
399    #[test]
400    fn search_query_new_rejects_empty_patterns() {
401        let result = SearchQuery::new(&[], SearchOptions::default());
402        assert!(result.is_err());
403    }
404
405    #[test]
406    fn search_query_new_stores_patterns_and_options() {
407        let patterns = vec!["foo".to_string(), "bar".to_string()];
408        let opts = SearchOptions {
409            case_mode: crate::search::options::CaseMode::Insensitive,
410            ..SearchOptions::default()
411        };
412        let search = SearchQuery::new(&patterns, opts).expect("create search");
413        assert_eq!(search.patterns(), &patterns);
414        assert!(search.opts.case_insensitive());
415    }
416}