longcipher_leptos_components/components/editor/
find_replace.rs

1//! Find and Replace functionality
2//!
3//! Provides search and replace capabilities for the editor.
4
5#[cfg(feature = "find-replace")]
6use regex::Regex;
7use serde::{Deserialize, Serialize};
8
9/// Options for find operations.
10#[derive(Debug, Clone, Default, Serialize, Deserialize)]
11pub struct FindOptions {
12    /// Whether the search is case-sensitive
13    pub case_sensitive: bool,
14    /// Whether to match whole words only
15    pub whole_word: bool,
16    /// Whether to use regex matching
17    pub use_regex: bool,
18    /// Whether to wrap around at document boundaries
19    pub wrap_around: bool,
20}
21
22/// A single find result.
23#[derive(Debug, Clone, Copy, PartialEq, Eq)]
24pub struct FindResult {
25    /// Start byte offset in the document
26    pub start: usize,
27    /// End byte offset in the document
28    pub end: usize,
29}
30
31impl FindResult {
32    /// Create a new find result.
33    #[must_use]
34    pub const fn new(start: usize, end: usize) -> Self {
35        Self { start, end }
36    }
37
38    /// Get the length of the match.
39    #[must_use]
40    pub const fn len(&self) -> usize {
41        self.end - self.start
42    }
43
44    /// Check if the match is empty.
45    #[must_use]
46    pub const fn is_empty(&self) -> bool {
47        self.start == self.end
48    }
49}
50
51/// State for find/replace operations.
52#[derive(Debug, Clone, Default)]
53pub struct FindState {
54    /// Current search query
55    pub query: String,
56    /// Replacement text
57    pub replacement: String,
58    /// Find options
59    pub options: FindOptions,
60    /// All matches in the current document
61    pub matches: Vec<FindResult>,
62    /// Index of the currently selected match
63    pub current_index: usize,
64    /// Whether the find panel is visible
65    pub is_visible: bool,
66    /// Whether replace mode is active
67    pub is_replace_mode: bool,
68}
69
70impl FindState {
71    /// Create a new find state.
72    #[must_use]
73    pub fn new() -> Self {
74        Self::default()
75    }
76
77    /// Set the search query and find all matches.
78    pub fn search(&mut self, text: &str) {
79        self.matches.clear();
80        self.current_index = 0;
81
82        if self.query.is_empty() {
83            return;
84        }
85
86        if self.options.use_regex {
87            self.search_regex(text);
88        } else {
89            self.search_literal(text);
90        }
91    }
92
93    /// Search using literal string matching.
94    fn search_literal(&mut self, text: &str) {
95        let search_text = if self.options.case_sensitive {
96            text.to_string()
97        } else {
98            text.to_lowercase()
99        };
100
101        let query = if self.options.case_sensitive {
102            self.query.clone()
103        } else {
104            self.query.to_lowercase()
105        };
106
107        let mut start = 0;
108        while let Some(pos) = search_text[start..].find(&query) {
109            let match_start = start + pos;
110            let match_end = match_start + self.query.len();
111
112            // Check whole word boundary
113            if self.options.whole_word {
114                let is_start_boundary = match_start == 0
115                    || !text[..match_start]
116                        .chars()
117                        .last()
118                        .map(|c| c.is_alphanumeric() || c == '_')
119                        .unwrap_or(false);
120
121                let is_end_boundary = match_end >= text.len()
122                    || !text[match_end..]
123                        .chars()
124                        .next()
125                        .map(|c| c.is_alphanumeric() || c == '_')
126                        .unwrap_or(false);
127
128                if !is_start_boundary || !is_end_boundary {
129                    start = match_start + 1;
130                    continue;
131                }
132            }
133
134            self.matches.push(FindResult::new(match_start, match_end));
135            start = match_end;
136        }
137    }
138
139    /// Search using regex.
140    #[cfg(feature = "find-replace")]
141    fn search_regex(&mut self, text: &str) {
142        let pattern = if self.options.case_sensitive {
143            self.query.clone()
144        } else {
145            format!("(?i){}", self.query)
146        };
147
148        let pattern = if self.options.whole_word {
149            format!(r"\b{}\b", pattern)
150        } else {
151            pattern
152        };
153
154        if let Ok(re) = Regex::new(&pattern) {
155            for m in re.find_iter(text) {
156                self.matches.push(FindResult::new(m.start(), m.end()));
157            }
158        }
159    }
160
161    /// Navigate to the next match.
162    ///
163    /// Returns the new current match if any.
164    pub fn next(&mut self) -> Option<FindResult> {
165        if self.matches.is_empty() {
166            return None;
167        }
168
169        self.current_index = (self.current_index + 1) % self.matches.len();
170        self.current_match()
171    }
172
173    /// Navigate to the previous match.
174    ///
175    /// Returns the new current match if any.
176    pub fn prev(&mut self) -> Option<FindResult> {
177        if self.matches.is_empty() {
178            return None;
179        }
180
181        self.current_index = if self.current_index == 0 {
182            self.matches.len() - 1
183        } else {
184            self.current_index - 1
185        };
186
187        self.current_match()
188    }
189
190    /// Get the current match.
191    #[must_use]
192    pub fn current_match(&self) -> Option<FindResult> {
193        self.matches.get(self.current_index).copied()
194    }
195
196    /// Get the match count.
197    #[must_use]
198    pub fn match_count(&self) -> usize {
199        self.matches.len()
200    }
201
202    /// Check if there are any matches.
203    #[must_use]
204    pub fn has_matches(&self) -> bool {
205        !self.matches.is_empty()
206    }
207
208    /// Replace the current match.
209    ///
210    /// Returns the new text if replacement was made.
211    pub fn replace_current(&self, text: &str) -> Option<String> {
212        let current = self.current_match()?;
213
214        let mut result = String::with_capacity(text.len());
215        result.push_str(&text[..current.start]);
216        result.push_str(&self.replacement);
217        result.push_str(&text[current.end..]);
218
219        Some(result)
220    }
221
222    /// Replace all matches.
223    ///
224    /// Returns the new text with all replacements made.
225    pub fn replace_all(&self, text: &str) -> String {
226        if self.matches.is_empty() {
227            return text.to_string();
228        }
229
230        let mut result = String::with_capacity(text.len());
231        let mut last_end = 0;
232
233        for m in &self.matches {
234            result.push_str(&text[last_end..m.start]);
235            result.push_str(&self.replacement);
236            last_end = m.end;
237        }
238
239        result.push_str(&text[last_end..]);
240        result
241    }
242
243    /// Show the find panel.
244    pub fn show(&mut self) {
245        self.is_visible = true;
246        self.is_replace_mode = false;
247    }
248
249    /// Show the find and replace panel.
250    pub fn show_replace(&mut self) {
251        self.is_visible = true;
252        self.is_replace_mode = true;
253    }
254
255    /// Hide the panel.
256    pub fn hide(&mut self) {
257        self.is_visible = false;
258    }
259
260    /// Clear the search state.
261    pub fn clear(&mut self) {
262        self.query.clear();
263        self.matches.clear();
264        self.current_index = 0;
265    }
266}
267
268#[cfg(test)]
269mod tests {
270    use super::*;
271
272    #[test]
273    fn test_find_literal() {
274        let mut state = FindState::new();
275        state.query = "hello".to_string();
276        state.search("hello world hello");
277
278        assert_eq!(state.match_count(), 2);
279        assert_eq!(state.matches[0], FindResult::new(0, 5));
280        assert_eq!(state.matches[1], FindResult::new(12, 17));
281    }
282
283    #[test]
284    fn test_find_case_insensitive() {
285        let mut state = FindState::new();
286        state.query = "Hello".to_string();
287        state.options.case_sensitive = false;
288        state.search("hello HELLO Hello");
289
290        assert_eq!(state.match_count(), 3);
291    }
292
293    #[test]
294    fn test_find_whole_word() {
295        let mut state = FindState::new();
296        state.query = "test".to_string();
297        state.options.whole_word = true;
298        state.search("test testing tested test");
299
300        assert_eq!(state.match_count(), 2);
301    }
302
303    #[test]
304    fn test_replace_all() {
305        let mut state = FindState::new();
306        state.query = "old".to_string();
307        state.replacement = "new".to_string();
308        state.search("old and old");
309
310        let result = state.replace_all("old and old");
311        assert_eq!(result, "new and new");
312    }
313}