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 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 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 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, ®ex_automata::Input::new(b"foo"))
109 .is_some()
110 );
111 assert!(
112 re.search_with(&mut cache, ®ex_automata::Input::new(b"bar"))
113 .is_some()
114 );
115 assert!(
116 re.search_with(&mut cache, ®ex_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, ®ex_automata::Input::new(b"a.c"))
130 .is_some()
131 );
132 assert!(
133 re.search_with(&mut cache, ®ex_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, ®ex_automata::Input::new(b"hello"))
147 .is_some()
148 );
149 assert!(
150 re.search_with(&mut cache, ®ex_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, ®ex_automata::Input::new(b"a cat here"))
164 .is_some()
165 );
166 assert!(
167 re.search_with(&mut cache, ®ex_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, ®ex_automata::Input::new(b"yes"))
181 .is_some()
182 );
183 assert!(
184 re.search_with(&mut cache, ®ex_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, ®ex_automata::Input::new(b"hello"))
237 .is_some()
238 );
239 }
240}