Skip to main content

iris_reader/
search.rs

1use regex::{Regex, RegexBuilder};
2
3use crate::renderer::NodeId;
4
5#[derive(Debug, Clone, PartialEq, Eq)]
6pub struct SearchMatch {
7    pub node_id: NodeId,
8    pub occurrence: usize,
9}
10
11#[derive(Debug, Default)]
12pub struct SearchState {
13    pub active: bool,
14    pub query: String,
15    pub matches: Vec<SearchMatch>,
16    pub current: Option<usize>,
17}
18
19impl SearchState {
20    pub fn open(&mut self) {
21        self.active = true;
22    }
23
24    pub fn cancel_input(&mut self) {
25        self.active = false;
26    }
27
28    pub fn accept_input(&mut self) {
29        self.active = false;
30    }
31
32    pub fn clear_query(&mut self) {
33        self.query.clear();
34        self.matches.clear();
35        self.current = None;
36    }
37
38    pub fn set_matches(&mut self, matches: Vec<SearchMatch>) {
39        self.matches = matches;
40        self.current = if self.matches.is_empty() {
41            None
42        } else {
43            Some(0)
44        };
45    }
46
47    pub fn next(&mut self) {
48        if self.matches.is_empty() {
49            self.current = None;
50            return;
51        }
52        self.current = Some(match self.current {
53            Some(index) => (index + 1) % self.matches.len(),
54            None => 0,
55        });
56    }
57
58    pub fn previous(&mut self) {
59        if self.matches.is_empty() {
60            self.current = None;
61            return;
62        }
63        self.current = Some(match self.current {
64            Some(0) | None => self.matches.len() - 1,
65            Some(index) => index - 1,
66        });
67    }
68
69    pub fn current_match(&self) -> Option<&SearchMatch> {
70        self.current.and_then(|index| self.matches.get(index))
71    }
72
73    pub fn position_label(&self) -> Option<(usize, usize)> {
74        self.current.map(|index| (index + 1, self.matches.len()))
75    }
76}
77
78#[derive(Debug, Clone)]
79pub struct SearchMatcher {
80    regex: Regex,
81}
82
83impl SearchMatcher {
84    pub fn new(query: &str) -> Option<Self> {
85        if query.is_empty() {
86            return None;
87        }
88
89        let case_sensitive = query.chars().any(char::is_uppercase);
90        let regex = RegexBuilder::new(&regex::escape(query))
91            .case_insensitive(!case_sensitive)
92            .build()
93            .expect("escaped search queries always compile");
94        Some(Self { regex })
95    }
96
97    pub fn count(&self, text: &str) -> usize {
98        self.regex.find_iter(text).count()
99    }
100
101    pub fn ranges(&self, text: &str) -> Vec<(usize, usize)> {
102        self.regex
103            .find_iter(text)
104            .map(|m| (m.start(), m.end()))
105            .collect()
106    }
107}
108
109#[cfg(test)]
110mod tests {
111    use super::*;
112
113    #[test]
114    fn search_uses_smart_case() {
115        let matcher = SearchMatcher::new("markdown").unwrap();
116        assert_eq!(matcher.count("Markdown markdown MARKDOWN"), 3);
117
118        let matcher = SearchMatcher::new("Markdown").unwrap();
119        assert_eq!(matcher.count("Markdown markdown MARKDOWN"), 1);
120    }
121
122    #[test]
123    fn navigation_wraps() {
124        let mut state = SearchState::default();
125        state.set_matches(vec![
126            SearchMatch {
127                node_id: 1,
128                occurrence: 0,
129            },
130            SearchMatch {
131                node_id: 2,
132                occurrence: 0,
133            },
134        ]);
135        state.next();
136        assert_eq!(state.current, Some(1));
137        state.next();
138        assert_eq!(state.current, Some(0));
139        state.previous();
140        assert_eq!(state.current, Some(1));
141    }
142}