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