longcipher_leptos_components/components/editor/
find_replace.rs1#[cfg(feature = "find-replace")]
6use regex::Regex;
7use serde::{Deserialize, Serialize};
8
9#[derive(Debug, Clone, Default, Serialize, Deserialize)]
11pub struct FindOptions {
12 pub case_sensitive: bool,
14 pub whole_word: bool,
16 pub use_regex: bool,
18 pub wrap_around: bool,
20}
21
22#[derive(Debug, Clone, Copy, PartialEq, Eq)]
24pub struct FindResult {
25 pub start: usize,
27 pub end: usize,
29}
30
31impl FindResult {
32 #[must_use]
34 pub const fn new(start: usize, end: usize) -> Self {
35 Self { start, end }
36 }
37
38 #[must_use]
40 pub const fn len(&self) -> usize {
41 self.end - self.start
42 }
43
44 #[must_use]
46 pub const fn is_empty(&self) -> bool {
47 self.start == self.end
48 }
49}
50
51#[derive(Debug, Clone, Default)]
53pub struct FindState {
54 pub query: String,
56 pub replacement: String,
58 pub options: FindOptions,
60 pub matches: Vec<FindResult>,
62 pub current_index: usize,
64 pub is_visible: bool,
66 pub is_replace_mode: bool,
68}
69
70impl FindState {
71 #[must_use]
73 pub fn new() -> Self {
74 Self::default()
75 }
76
77 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 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 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 #[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 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 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 #[must_use]
192 pub fn current_match(&self) -> Option<FindResult> {
193 self.matches.get(self.current_index).copied()
194 }
195
196 #[must_use]
198 pub fn match_count(&self) -> usize {
199 self.matches.len()
200 }
201
202 #[must_use]
204 pub fn has_matches(&self) -> bool {
205 !self.matches.is_empty()
206 }
207
208 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 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 pub fn show(&mut self) {
245 self.is_visible = true;
246 self.is_replace_mode = false;
247 }
248
249 pub fn show_replace(&mut self) {
251 self.is_visible = true;
252 self.is_replace_mode = true;
253 }
254
255 pub fn hide(&mut self) {
257 self.is_visible = false;
258 }
259
260 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}