Skip to main content

sift_core/search/pattern/
mod.rs

1pub mod error;
2
3use regex_automata::meta::Regex;
4use regex_syntax::escape;
5
6use crate::search::SearchError;
7use crate::search::options::SearchMatchFlags;
8
9#[derive(Debug, Clone, Copy, Default)]
10pub struct PatternCompiler {
11    flags: SearchMatchFlags,
12    case_insensitive: bool,
13}
14
15impl PatternCompiler {
16    #[must_use]
17    pub const fn new() -> Self {
18        Self {
19            flags: SearchMatchFlags::empty(),
20            case_insensitive: false,
21        }
22    }
23
24    #[must_use]
25    pub fn fixed_strings(mut self, on: bool) -> Self {
26        self.flags.set(SearchMatchFlags::FIXED_STRINGS, on);
27        self
28    }
29
30    #[must_use]
31    pub fn word_regexp(mut self, on: bool) -> Self {
32        self.flags.set(SearchMatchFlags::WORD_REGEXP, on);
33        self
34    }
35
36    #[must_use]
37    pub fn line_regexp(mut self, on: bool) -> Self {
38        self.flags.set(SearchMatchFlags::LINE_REGEXP, on);
39        self
40    }
41
42    #[must_use]
43    pub const fn case_insensitive(mut self, on: bool) -> Self {
44        self.case_insensitive = on;
45        self
46    }
47
48    #[must_use]
49    pub fn shape(&self, pattern: &str) -> String {
50        let mut s = if self.flags.contains(SearchMatchFlags::FIXED_STRINGS) {
51            escape(pattern)
52        } else {
53            pattern.to_string()
54        };
55        if self.flags.contains(SearchMatchFlags::WORD_REGEXP) {
56            s = format!(r"\b(?:{s})\b");
57        }
58        if self.flags.contains(SearchMatchFlags::LINE_REGEXP) {
59            s = format!("^(?:{s})$");
60        }
61        s
62    }
63
64    /// Compiles multiple patterns into a single regex.
65    ///
66    /// # Errors
67    ///
68    /// Returns `SearchError::RegexBuild` if the combined pattern is invalid.
69    pub fn compile(&self, patterns: &[&str]) -> Result<Regex, SearchError> {
70        let mut branches: Vec<String> = patterns.iter().map(|p| self.shape(p)).collect();
71        let combined = if branches.len() == 1 {
72            branches.swap_remove(0)
73        } else {
74            branches
75                .into_iter()
76                .map(|b| format!("(?:{b})"))
77                .collect::<Vec<_>>()
78                .join("|")
79        };
80        let mut builder = Regex::builder();
81        if self.case_insensitive {
82            builder.syntax(regex_automata::util::syntax::Config::new().case_insensitive(true));
83        }
84        builder
85            .build(&combined)
86            .map_err(|e| SearchError::RegexBuild(format!("regex compilation failed: {e}")))
87    }
88
89    /// Compiles a single pattern into a regex.
90    ///
91    /// # Errors
92    ///
93    /// Returns `SearchError::RegexBuild` if the pattern is invalid.
94    pub fn compile_one(&self, pattern: &str) -> Result<Regex, SearchError> {
95        self.compile(&[pattern])
96    }
97}
98
99#[cfg(test)]
100mod tests {
101    use super::*;
102
103    #[test]
104    fn alternation_matches_either_pattern() {
105        let re = PatternCompiler::new().compile(&["foo", "bar"]).unwrap();
106        let mut cache = regex_automata::meta::Cache::new(&re);
107        assert!(
108            re.search_with(&mut cache, &regex_automata::Input::new(b"foo"))
109                .is_some()
110        );
111        assert!(
112            re.search_with(&mut cache, &regex_automata::Input::new(b"bar"))
113                .is_some()
114        );
115        assert!(
116            re.search_with(&mut cache, &regex_automata::Input::new(b"baz"))
117                .is_none()
118        );
119    }
120
121    #[test]
122    fn fixed_strings_escape_metacharacters() {
123        let re = PatternCompiler::new()
124            .fixed_strings(true)
125            .compile(&[r"a.c"])
126            .unwrap();
127        let mut cache = regex_automata::meta::Cache::new(&re);
128        assert!(
129            re.search_with(&mut cache, &regex_automata::Input::new(b"a.c"))
130                .is_some()
131        );
132        assert!(
133            re.search_with(&mut cache, &regex_automata::Input::new(b"abc"))
134                .is_none()
135        );
136    }
137
138    #[test]
139    fn case_insensitive() {
140        let re = PatternCompiler::new()
141            .case_insensitive(true)
142            .compile(&["Hello"])
143            .unwrap();
144        let mut cache = regex_automata::meta::Cache::new(&re);
145        assert!(
146            re.search_with(&mut cache, &regex_automata::Input::new(b"hello"))
147                .is_some()
148        );
149        assert!(
150            re.search_with(&mut cache, &regex_automata::Input::new(b"HELLO"))
151                .is_some()
152        );
153    }
154
155    #[test]
156    fn word_regexp() {
157        let re = PatternCompiler::new()
158            .word_regexp(true)
159            .compile(&["cat"])
160            .unwrap();
161        let mut cache = regex_automata::meta::Cache::new(&re);
162        assert!(
163            re.search_with(&mut cache, &regex_automata::Input::new(b"a cat here"))
164                .is_some()
165        );
166        assert!(
167            re.search_with(&mut cache, &regex_automata::Input::new(b"concat"))
168                .is_none()
169        );
170    }
171
172    #[test]
173    fn line_regexp() {
174        let re = PatternCompiler::new()
175            .line_regexp(true)
176            .compile(&["yes"])
177            .unwrap();
178        let mut cache = regex_automata::meta::Cache::new(&re);
179        assert!(
180            re.search_with(&mut cache, &regex_automata::Input::new(b"yes"))
181                .is_some()
182        );
183        assert!(
184            re.search_with(&mut cache, &regex_automata::Input::new(b"oh yes sir"))
185                .is_none()
186        );
187    }
188
189    #[test]
190    fn invalid_regex_returns_err() {
191        assert!(PatternCompiler::new().compile(&["("]).is_err());
192    }
193
194    #[test]
195    fn shape_fixed_strings_escapes_metacharacters() {
196        let compiler = PatternCompiler::new().fixed_strings(true);
197        assert_eq!(compiler.shape("a.c"), r"a\.c");
198        assert_eq!(compiler.shape("foo*bar"), r"foo\*bar");
199    }
200
201    #[test]
202    fn shape_word_regexp_wraps_in_word_boundary() {
203        let compiler = PatternCompiler::new().word_regexp(true);
204        let shaped = compiler.shape("cat");
205        assert!(shaped.contains(r"\b"));
206    }
207
208    #[test]
209    fn shape_line_regexp_wraps_in_anchors() {
210        let compiler = PatternCompiler::new().line_regexp(true);
211        let shaped = compiler.shape("yes");
212        assert!(shaped.starts_with('^'));
213        assert!(shaped.ends_with('$'));
214    }
215
216    #[test]
217    fn shape_line_and_word_combined() {
218        let compiler = PatternCompiler::new().line_regexp(true).word_regexp(true);
219        let shaped = compiler.shape("cat");
220        assert!(shaped.starts_with('^'));
221        assert!(shaped.ends_with('$'));
222        assert!(shaped.contains(r"\b"));
223    }
224
225    #[test]
226    fn shape_no_flags_returns_pattern_unchanged() {
227        let compiler = PatternCompiler::new();
228        assert_eq!(compiler.shape("hello"), "hello");
229    }
230
231    #[test]
232    fn compile_one_delegates_to_compile() {
233        let re = PatternCompiler::new().compile_one("hello").unwrap();
234        let mut cache = regex_automata::meta::Cache::new(&re);
235        assert!(
236            re.search_with(&mut cache, &regex_automata::Input::new(b"hello"))
237                .is_some()
238        );
239    }
240}