Skip to main content

sift_core/search/query/
mod.rs

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