Skip to main content

par_term/search/
engine.rs

1//! Search engine for terminal scrollback.
2
3use super::types::{SearchConfig, SearchMatch};
4use regex::{Regex, RegexBuilder};
5
6/// Search engine that performs text searches on terminal content.
7pub struct SearchEngine {
8    /// Cached compiled regex for the current query.
9    cached_regex: Option<(String, bool, Regex)>, // (pattern, case_sensitive, compiled)
10}
11
12impl Default for SearchEngine {
13    fn default() -> Self {
14        Self::new()
15    }
16}
17
18impl SearchEngine {
19    /// Create a new search engine.
20    pub fn new() -> Self {
21        Self { cached_regex: None }
22    }
23
24    /// Search through lines of text and return all matches.
25    ///
26    /// # Arguments
27    /// * `lines` - Iterator of (line_index, line_text) pairs
28    /// * `query` - The search query
29    /// * `config` - Search configuration options
30    ///
31    /// # Returns
32    /// A vector of SearchMatch containing all matches found.
33    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    /// Perform plain text search.
53    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        // Query length in characters (not bytes)
69        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                // Convert byte offset to character offset for cell positioning
83                let char_column = Self::byte_offset_to_char_offset(&search_line, byte_offset);
84
85                // Check whole word matching if enabled (using byte offset for string slicing)
86                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                // Avoid infinite loops on empty matches
97                if query.is_empty() {
98                    break;
99                }
100            }
101        }
102    }
103
104    /// Perform regex search.
105    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        // Try to compile or reuse cached regex
115        let regex = match self.get_or_compile_regex(query, config.case_sensitive) {
116            Ok(re) => re.clone(), // Clone to avoid borrow issues
117            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                // Convert byte offsets to character offsets for cell positioning
129                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                // Check whole word matching if enabled (using byte offsets for string slicing)
133                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    /// Convert a byte offset to a character offset in a string.
145    /// This is needed because String::find() returns byte offsets, but we need
146    /// character offsets for cell positioning (each cell = 1 character).
147    fn byte_offset_to_char_offset(s: &str, byte_offset: usize) -> usize {
148        s[..byte_offset].chars().count()
149    }
150
151    /// Get cached regex or compile a new one.
152    fn get_or_compile_regex(
153        &mut self,
154        pattern: &str,
155        case_sensitive: bool,
156    ) -> Result<&Regex, regex::Error> {
157        // Check if we have a cached regex that matches
158        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    /// Check if a match at the given position is a whole word.
180    fn is_whole_word_static(line: &str, start: usize, length: usize) -> bool {
181        let end = start + length;
182
183        // Check character before the match
184        if start > 0
185            && let Some(c) = line[..start].chars().last()
186            && (c.is_alphanumeric() || c == '_')
187        {
188            return false;
189        }
190
191        // Check character after the match
192        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    /// Clear the cached regex.
203    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        // Emoji folder icon (4 bytes in UTF-8) followed by space and text
325        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        // "down" should be found at character position 2 (after "📁 ")
331        // NOT byte position 5 (4 bytes for emoji + 1 for space)
332        assert_eq!(matches.len(), 1);
333        assert_eq!(matches[0].line, 0);
334        assert_eq!(matches[0].column, 2); // Character offset, not byte offset
335        assert_eq!(matches[0].length, 4);
336    }
337
338    #[test]
339    fn test_unicode_multiple_emoji() {
340        let mut engine = SearchEngine::new();
341        // Multiple emoji before the search term
342        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        // "party" starts at character 4 (3 emoji + 1 space)
348        // NOT byte position 13 (3*4 bytes + 1 space)
349        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        // Invalid regex should return empty results
364        let matches = engine.search(make_lines(&lines), "[invalid", &config);
365
366        assert!(matches.is_empty());
367    }
368}