1use super::types::{SearchConfig, SearchMatch};
4use regex::{Regex, RegexBuilder};
5
6pub struct SearchEngine {
8 cached_regex: Option<(String, bool, Regex)>, }
11
12impl Default for SearchEngine {
13 fn default() -> Self {
14 Self::new()
15 }
16}
17
18impl SearchEngine {
19 pub fn new() -> Self {
21 Self { cached_regex: None }
22 }
23
24 pub fn search<I>(&mut self, lines: I, query: &str, config: &SearchConfig) -> Vec<SearchMatch>
34 where
35 I: Iterator<Item = (usize, String)>,
36 {
37 if query.is_empty() {
38 return Vec::new();
39 }
40
41 let mut matches = Vec::new();
42
43 if config.use_regex {
44 self.search_regex(lines, query, config, &mut matches);
45 } else {
46 self.search_plain(lines, query, config, &mut matches);
47 }
48
49 matches
50 }
51
52 fn search_plain<I>(
54 &self,
55 lines: I,
56 query: &str,
57 config: &SearchConfig,
58 matches: &mut Vec<SearchMatch>,
59 ) where
60 I: Iterator<Item = (usize, String)>,
61 {
62 let query_lower = if config.case_sensitive {
63 query.to_string()
64 } else {
65 query.to_lowercase()
66 };
67
68 let query_char_len = query.chars().count();
70
71 for (line_idx, line) in lines {
72 let search_line = if config.case_sensitive {
73 line.clone()
74 } else {
75 line.to_lowercase()
76 };
77
78 let mut start_byte = 0;
79 while let Some(pos) = search_line[start_byte..].find(&query_lower) {
80 let byte_offset = start_byte + pos;
81
82 let char_column = Self::byte_offset_to_char_offset(&search_line, byte_offset);
84
85 if config.whole_word
87 && !Self::is_whole_word_static(&line, byte_offset, query_lower.len())
88 {
89 start_byte = byte_offset + 1;
90 continue;
91 }
92
93 matches.push(SearchMatch::new(line_idx, char_column, query_char_len));
94 start_byte = byte_offset + query_lower.len().max(1);
95
96 if query.is_empty() {
98 break;
99 }
100 }
101 }
102 }
103
104 fn search_regex<I>(
106 &mut self,
107 lines: I,
108 query: &str,
109 config: &SearchConfig,
110 matches: &mut Vec<SearchMatch>,
111 ) where
112 I: Iterator<Item = (usize, String)>,
113 {
114 let regex = match self.get_or_compile_regex(query, config.case_sensitive) {
116 Ok(re) => re.clone(), Err(e) => {
118 log::debug!("Invalid regex pattern '{}': {}", query, e);
119 return;
120 }
121 };
122
123 for (line_idx, line) in lines {
124 for mat in regex.find_iter(&line) {
125 let byte_start = mat.start();
126 let byte_end = mat.end();
127
128 let char_column = Self::byte_offset_to_char_offset(&line, byte_start);
130 let char_length = Self::byte_offset_to_char_offset(&line, byte_end) - char_column;
131
132 if config.whole_word
134 && !Self::is_whole_word_static(&line, byte_start, byte_end - byte_start)
135 {
136 continue;
137 }
138
139 matches.push(SearchMatch::new(line_idx, char_column, char_length));
140 }
141 }
142 }
143
144 fn byte_offset_to_char_offset(s: &str, byte_offset: usize) -> usize {
148 s[..byte_offset].chars().count()
149 }
150
151 fn get_or_compile_regex(
153 &mut self,
154 pattern: &str,
155 case_sensitive: bool,
156 ) -> Result<&Regex, regex::Error> {
157 let needs_recompile = match &self.cached_regex {
159 Some((cached_pattern, cached_case, _)) => {
160 cached_pattern != pattern || *cached_case != case_sensitive
161 }
162 None => true,
163 };
164
165 if needs_recompile {
166 let regex = RegexBuilder::new(pattern)
167 .case_insensitive(!case_sensitive)
168 .build()?;
169 self.cached_regex = Some((pattern.to_string(), case_sensitive, regex));
170 }
171
172 Ok(&self
173 .cached_regex
174 .as_ref()
175 .expect("cached_regex was just set to Some above if it was None")
176 .2)
177 }
178
179 fn is_whole_word_static(line: &str, start: usize, length: usize) -> bool {
181 let end = start + length;
182
183 if start > 0
185 && let Some(c) = line[..start].chars().last()
186 && (c.is_alphanumeric() || c == '_')
187 {
188 return false;
189 }
190
191 if end < line.len()
193 && let Some(c) = line[end..].chars().next()
194 && (c.is_alphanumeric() || c == '_')
195 {
196 return false;
197 }
198
199 true
200 }
201
202 pub fn clear_cache(&mut self) {
204 self.cached_regex = None;
205 }
206}
207
208#[cfg(test)]
209mod tests {
210 use super::*;
211
212 fn make_lines<'a>(texts: &'a [&'a str]) -> impl Iterator<Item = (usize, String)> + 'a {
213 texts.iter().enumerate().map(|(i, s)| (i, s.to_string()))
214 }
215
216 #[test]
217 fn test_plain_search_case_insensitive() {
218 let mut engine = SearchEngine::new();
219 let lines: Vec<&str> = vec!["Hello World", "hello there", "HELLO WORLD"];
220 let config = SearchConfig::default();
221
222 let matches = engine.search(make_lines(&lines), "hello", &config);
223
224 assert_eq!(matches.len(), 3);
225 assert_eq!(matches[0], SearchMatch::new(0, 0, 5));
226 assert_eq!(matches[1], SearchMatch::new(1, 0, 5));
227 assert_eq!(matches[2], SearchMatch::new(2, 0, 5));
228 }
229
230 #[test]
231 fn test_plain_search_case_sensitive() {
232 let mut engine = SearchEngine::new();
233 let lines: Vec<&str> = vec!["Hello World", "hello there", "HELLO WORLD"];
234 let config = SearchConfig {
235 case_sensitive: true,
236 ..Default::default()
237 };
238
239 let matches = engine.search(make_lines(&lines), "hello", &config);
240
241 assert_eq!(matches.len(), 1);
242 assert_eq!(matches[0], SearchMatch::new(1, 0, 5));
243 }
244
245 #[test]
246 fn test_plain_search_multiple_matches_per_line() {
247 let mut engine = SearchEngine::new();
248 let lines: Vec<&str> = vec!["foo bar foo baz foo"];
249 let config = SearchConfig::default();
250
251 let matches = engine.search(make_lines(&lines), "foo", &config);
252
253 assert_eq!(matches.len(), 3);
254 assert_eq!(matches[0], SearchMatch::new(0, 0, 3));
255 assert_eq!(matches[1], SearchMatch::new(0, 8, 3));
256 assert_eq!(matches[2], SearchMatch::new(0, 16, 3));
257 }
258
259 #[test]
260 fn test_whole_word_matching() {
261 let mut engine = SearchEngine::new();
262 let lines: Vec<&str> = vec!["foobar foo barfoo"];
263 let config = SearchConfig {
264 whole_word: true,
265 ..Default::default()
266 };
267
268 let matches = engine.search(make_lines(&lines), "foo", &config);
269
270 assert_eq!(matches.len(), 1);
271 assert_eq!(matches[0], SearchMatch::new(0, 7, 3));
272 }
273
274 #[test]
275 fn test_regex_search() {
276 let mut engine = SearchEngine::new();
277 let lines: Vec<&str> = vec![
278 "error: something failed",
279 "warning: check this",
280 "error: again",
281 ];
282 let config = SearchConfig {
283 use_regex: true,
284 ..Default::default()
285 };
286
287 let matches = engine.search(make_lines(&lines), "error:", &config);
288
289 assert_eq!(matches.len(), 2);
290 assert_eq!(matches[0], SearchMatch::new(0, 0, 6));
291 assert_eq!(matches[1], SearchMatch::new(2, 0, 6));
292 }
293
294 #[test]
295 fn test_regex_pattern() {
296 let mut engine = SearchEngine::new();
297 let lines: Vec<&str> = vec!["test123", "test456", "notest"];
298 let config = SearchConfig {
299 use_regex: true,
300 ..Default::default()
301 };
302
303 let matches = engine.search(make_lines(&lines), r"test\d+", &config);
304
305 assert_eq!(matches.len(), 2);
306 assert_eq!(matches[0], SearchMatch::new(0, 0, 7));
307 assert_eq!(matches[1], SearchMatch::new(1, 0, 7));
308 }
309
310 #[test]
311 fn test_empty_query() {
312 let mut engine = SearchEngine::new();
313 let lines: Vec<&str> = vec!["some text"];
314 let config = SearchConfig::default();
315
316 let matches = engine.search(make_lines(&lines), "", &config);
317
318 assert!(matches.is_empty());
319 }
320
321 #[test]
322 fn test_unicode_character_offsets() {
323 let mut engine = SearchEngine::new();
324 let lines: Vec<&str> = vec!["📁 Downloads", "normal text"];
326 let config = SearchConfig::default();
327
328 let matches = engine.search(make_lines(&lines), "down", &config);
329
330 assert_eq!(matches.len(), 1);
333 assert_eq!(matches[0].line, 0);
334 assert_eq!(matches[0].column, 2); assert_eq!(matches[0].length, 4);
336 }
337
338 #[test]
339 fn test_unicode_multiple_emoji() {
340 let mut engine = SearchEngine::new();
341 let lines: Vec<&str> = vec!["🎉🎊🎁 party time"];
343 let config = SearchConfig::default();
344
345 let matches = engine.search(make_lines(&lines), "party", &config);
346
347 assert_eq!(matches.len(), 1);
350 assert_eq!(matches[0].column, 4);
351 assert_eq!(matches[0].length, 5);
352 }
353
354 #[test]
355 fn test_invalid_regex() {
356 let mut engine = SearchEngine::new();
357 let lines: Vec<&str> = vec!["some text"];
358 let config = SearchConfig {
359 use_regex: true,
360 ..Default::default()
361 };
362
363 let matches = engine.search(make_lines(&lines), "[invalid", &config);
365
366 assert!(matches.is_empty());
367 }
368}