Skip to main content

sift_core/search/query/
mod.rs

1use std::path::PathBuf;
2use std::time::Instant;
3#[cfg(test)]
4use std::{io, path::Path};
5
6#[cfg(test)]
7use grep_matcher::Matcher;
8use grep_regex::RegexMatcher;
9use std::sync::OnceLock;
10
11#[cfg(test)]
12use crate::Candidate;
13use crate::query::{QueryFlags, QuerySpec};
14use crate::search::SearchError;
15use crate::search::SearchOutcome;
16use crate::search::emit::stats::{SearchStats, TextStatsCounters};
17#[cfg(test)]
18use crate::search::filter::CandidateFilter;
19#[cfg(test)]
20use crate::search::filter::config::{
21    CandidateFilterConfig, HiddenMode, IgnoreConfig, VisibilityConfig,
22};
23use crate::search::options::SearchOptions;
24use crate::search::output::SearchOutputFormat;
25#[cfg(test)]
26use crate::search::output::mode::MatchEmissionMode;
27use crate::search::output::mode::SearchMode;
28use crate::search::request::SearchExecution;
29
30pub mod matcher;
31
32#[derive(Debug, Clone, PartialEq, Eq)]
33pub struct Match {
34    pub file: std::path::PathBuf,
35    pub line: usize,
36    pub text: String,
37}
38
39#[derive(Debug)]
40pub struct SearchQuery {
41    patterns: Vec<String>,
42    opts: SearchOptions,
43    matcher: OnceLock<RegexMatcher>,
44}
45
46impl SearchQuery {
47    /// Creates a new search query from patterns and options.
48    ///
49    /// # Errors
50    ///
51    /// Returns `SearchError::EmptyPatterns` if the pattern list is empty.
52    pub fn new(patterns: &[String], opts: SearchOptions) -> Result<Self, SearchError> {
53        if patterns.is_empty() {
54            return Err(SearchError::EmptyPatterns);
55        }
56        Ok(Self {
57            patterns: patterns.to_vec(),
58            opts,
59            matcher: OnceLock::new(),
60        })
61    }
62
63    #[must_use]
64    pub fn patterns(&self) -> &[String] {
65        &self.patterns
66    }
67
68    #[must_use]
69    pub const fn opts(&self) -> &SearchOptions {
70        &self.opts
71    }
72
73    pub(crate) fn build_query_spec(&self) -> QuerySpec<'_> {
74        let mut flags = QueryFlags::empty();
75        if self.opts.fixed_strings() {
76            flags |= QueryFlags::FIXED_STRINGS;
77        }
78        if self.opts.case_insensitive() {
79            flags |= QueryFlags::CASE_INSENSITIVE;
80        }
81        if self.opts.word_regexp() {
82            flags |= QueryFlags::WORD_REGEXP;
83        }
84        if self.opts.line_regexp() {
85            flags |= QueryFlags::LINE_REGEXP;
86        }
87        if self.opts.invert_match() {
88            flags |= QueryFlags::INVERT_MATCH;
89        }
90        QuerySpec {
91            patterns: &self.patterns,
92            flags,
93        }
94    }
95
96    pub(crate) fn search(
97        &self,
98        execution: &SearchExecution<'_>,
99    ) -> crate::Result<(SearchOutcome, Vec<PathBuf>)> {
100        if self.opts.max_results == Some(0) {
101            return Err(SearchError::InvalidMaxCount.into());
102        }
103
104        let output = execution.output;
105        let candidates = execution.candidates;
106
107        if candidates.is_empty() {
108            return Ok((
109                SearchOutcome {
110                    matched: false,
111                    stats: execution.collect.stats.then_some(SearchStats::default()),
112                },
113                Vec::new(),
114            ));
115        }
116
117        let search_start = Instant::now();
118        let matcher = self.resolve_matcher()?;
119
120        let (did_match, stats, hits) = match output.format {
121            SearchOutputFormat::Json => match output.mode {
122                SearchMode::Standard | SearchMode::OnlyMatching => {
123                    let mut stats = execution.collect.stats.then_some(SearchStats::default());
124                    let scan = crate::search::scan::json::JsonScan::new(
125                        self,
126                        matcher,
127                        output,
128                        search_start,
129                    );
130                    let json_matched = scan.run(candidates, stats.as_mut())?;
131                    (json_matched, stats, Vec::new())
132                }
133                _ => return Err(SearchError::JsonOutputIncompatibleMode.into()),
134            },
135            SearchOutputFormat::Text => {
136                let (did_match, stats, hits) =
137                    self.run_text_output(candidates, matcher, execution, search_start)?;
138                (did_match, stats, hits)
139            }
140        };
141
142        Ok((
143            SearchOutcome {
144                matched: did_match,
145                stats,
146            },
147            hits,
148        ))
149    }
150
151    fn resolve_matcher(&self) -> Result<&RegexMatcher, SearchError> {
152        if let Some(m) = self.matcher.get() {
153            return Ok(m);
154        }
155        let m = self.build_matcher()?;
156        let _ = self.matcher.set(m);
157        Ok(self.matcher.get().expect("just initialised"))
158    }
159
160    fn run_text_output(
161        &self,
162        candidates: &[crate::Candidate],
163        matcher: &RegexMatcher,
164        execution: &SearchExecution<'_>,
165        search_start: Instant,
166    ) -> crate::Result<(bool, Option<SearchStats>, Vec<PathBuf>)> {
167        let output = execution.output;
168        let collect = execution.collect;
169        let counters = TextStatsCounters::new(collect.stats);
170
171        let (did_match, hits) = match output.mode {
172            SearchMode::Standard | SearchMode::OnlyMatching => {
173                let scan = crate::search::scan::standard::StandardScan::new(
174                    self,
175                    matcher,
176                    output,
177                    execution.separators,
178                    &counters,
179                );
180                scan.run(candidates, collect)?
181            }
182            SearchMode::Count
183            | SearchMode::CountMatches
184            | SearchMode::FilesWithMatches
185            | SearchMode::FilesWithoutMatch => {
186                let scan = crate::search::scan::summary::SummaryScan::new(
187                    self, matcher, output, &counters,
188                );
189                scan.run(candidates, collect)?
190            }
191        };
192
193        let bytes_searched = if collect.stats {
194            crate::Candidate::total_file_bytes(candidates)
195        } else {
196            0
197        };
198        let stats = counters.finish(candidates.len(), bytes_searched, search_start.elapsed());
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}