sift_core/search/pattern/
mod.rs1pub 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 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 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, ®ex_automata::Input::new(b"foo"))
115 .is_some()
116 );
117 assert!(
118 re.search_with(&mut cache, ®ex_automata::Input::new(b"bar"))
119 .is_some()
120 );
121 assert!(
122 re.search_with(&mut cache, ®ex_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, ®ex_automata::Input::new(b"a.c"))
136 .is_some()
137 );
138 assert!(
139 re.search_with(&mut cache, ®ex_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, ®ex_automata::Input::new(b"hello"))
153 .is_some()
154 );
155 assert!(
156 re.search_with(&mut cache, ®ex_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, ®ex_automata::Input::new(b"a cat here"))
170 .is_some()
171 );
172 assert!(
173 re.search_with(&mut cache, ®ex_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, ®ex_automata::Input::new(b"yes"))
187 .is_some()
188 );
189 assert!(
190 re.search_with(&mut cache, ®ex_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, ®ex_automata::Input::new(b"hello"))
243 .is_some()
244 );
245 }
246}