kimun_notes/components/text_editor/
find_replace.rs1use regex::Regex;
13
14#[derive(Debug, Clone)]
16pub struct FindPattern {
17 re: Regex,
18 case_sensitive: bool,
22 has_captures: bool,
25}
26
27impl FindPattern {
28 pub fn compile(query: &str) -> Result<Self, regex::Error> {
35 let case_sensitive = query.chars().any(char::is_uppercase);
36 let re = if case_sensitive {
37 Regex::new(query)?
38 } else {
39 Regex::new(&format!("(?i){query}"))?
40 };
41 let has_captures = re.captures_len() > 1;
46 Ok(Self {
47 re,
48 case_sensitive,
49 has_captures,
50 })
51 }
52
53 pub fn as_regex(&self) -> &Regex {
54 &self.re
55 }
56
57 pub fn case_sensitive(&self) -> bool {
58 self.case_sensitive
59 }
60
61 pub fn has_captures(&self) -> bool {
62 self.has_captures
63 }
64
65 pub fn count_matches<S: AsRef<str>>(&self, rows: impl Iterator<Item = S>) -> usize {
69 rows.map(|row| self.re.find_iter(row.as_ref()).count())
70 .sum()
71 }
72
73 pub fn match_spans<S: AsRef<str>>(
81 &self,
82 rows: impl Iterator<Item = S>,
83 ) -> Vec<(usize, usize, usize)> {
84 let mut out = Vec::new();
85 for (row, line) in rows.enumerate() {
86 let line = line.as_ref();
87 for m in self.re.find_iter(line) {
88 let start = line[..m.start()].chars().count();
89 let end = start + line[m.range()].chars().count();
90 out.push((row, start, end));
91 }
92 }
93 out
94 }
95
96 pub fn expand(&self, caps: ®ex::Captures<'_>, replacement: &str) -> String {
110 if !self.has_captures {
111 return replacement.to_string();
112 }
113 let mut out = String::new();
114 let mut rest = replacement;
115 while let Some(dollar) = rest.find('$') {
116 out.push_str(&rest[..dollar]);
117 let tail = &rest[dollar..];
118 if let Some(after) = tail.strip_prefix("$$") {
120 out.push('$');
121 rest = after;
122 continue;
123 }
124 let end = reference_end(tail);
128 let reference = &tail[..end];
129 let mut expanded = String::new();
130 caps.expand(reference, &mut expanded);
131 if expanded.is_empty() && !group_exists(caps, reference) {
132 out.push_str(reference);
133 } else {
134 out.push_str(&expanded);
135 }
136 rest = &tail[end..];
137 }
138 out.push_str(rest);
139 out
140 }
141}
142
143fn reference_end(s: &str) -> usize {
148 debug_assert!(s.starts_with('$'));
149 if let Some(rest) = s.strip_prefix("${") {
150 return match rest.find('}') {
151 Some(close) => 2 + close + 1,
152 None => s.len(),
153 };
154 }
155 let name_len = s[1..]
156 .find(|c: char| !c.is_ascii_alphanumeric() && c != '_')
157 .unwrap_or(s.len() - 1);
158 1 + name_len
159}
160
161fn group_exists(caps: ®ex::Captures<'_>, reference: &str) -> bool {
165 let name = reference
166 .trim_start_matches('$')
167 .trim_start_matches('{')
168 .trim_end_matches('}');
169 if name.is_empty() {
170 return false;
171 }
172 match name.parse::<usize>() {
173 Ok(index) => index < caps.len(),
174 Err(_) => caps.name(name).is_some(),
175 }
176}
177
178#[derive(Debug, Clone, Copy, PartialEq, Eq)]
181pub struct PreviewSpan {
182 pub row: usize,
183 pub start: usize,
185 pub end: usize,
187 pub is_current: bool,
190}
191
192#[derive(Debug, Clone, PartialEq, Eq)]
194pub struct Preview {
195 pub lines: Vec<String>,
196 pub spans: Vec<PreviewSpan>,
197}
198
199pub fn build_preview<S: AsRef<str>>(
212 pattern: &FindPattern,
213 rows: impl Iterator<Item = S>,
214 replacement: &str,
215 current: Option<(usize, usize)>,
216) -> Preview {
217 let mut out_lines = Vec::new();
218 let mut spans = Vec::new();
219
220 for (row, line) in rows.enumerate() {
221 let line = line.as_ref();
222 let mut rebuilt = String::with_capacity(line.len());
223 let mut last_byte = 0usize;
225 let mut out_chars = 0usize;
226
227 for caps in pattern.as_regex().captures_iter(line) {
228 let m = caps.get(0).expect("group 0 always exists");
229 let gap = &line[last_byte..m.start()];
230 rebuilt.push_str(gap);
231 out_chars += gap.chars().count();
232
233 let expanded = pattern.expand(&caps, replacement);
234 let expanded_chars = expanded.chars().count();
235 let match_start_chars = line[..m.start()].chars().count();
236 let is_current = current == Some((row, match_start_chars));
237
238 rebuilt.push_str(&expanded);
239 spans.push(PreviewSpan {
240 row,
241 start: out_chars,
242 end: out_chars + expanded_chars,
243 is_current,
244 });
245 out_chars += expanded_chars;
246 last_byte = m.end();
247
248 if m.start() == m.end() && m.end() == last_byte {
252 continue;
253 }
254 }
255
256 rebuilt.push_str(&line[last_byte..]);
257 out_lines.push(rebuilt);
258 }
259
260 Preview {
261 lines: out_lines,
262 spans,
263 }
264}
265
266pub fn replace_all(
272 pattern: &FindPattern,
273 lines: &[String],
274 replacement: &str,
275) -> Option<(Vec<String>, usize)> {
276 let preview = build_preview(pattern, lines.iter(), replacement, None);
277 let count = preview.spans.len();
278 if count == 0 {
279 return None;
280 }
281 Some((preview.lines, count))
282}
283
284#[cfg(test)]
285mod tests {
286 use super::*;
287
288 fn lines(v: &[&str]) -> Vec<String> {
289 v.iter().map(|s| s.to_string()).collect()
290 }
291
292 #[test]
295 fn lowercase_pattern_matches_any_case() {
296 let p = FindPattern::compile("todo").unwrap();
297 assert!(!p.case_sensitive());
298 assert_eq!(p.count_matches(lines(&["todo Todo TODO"]).iter()), 3);
299 }
300
301 #[test]
302 fn pattern_with_uppercase_is_exact() {
303 let p = FindPattern::compile("Todo").unwrap();
304 assert!(p.case_sensitive());
305 assert_eq!(p.count_matches(lines(&["todo Todo TODO"]).iter()), 1);
306 }
307
308 #[test]
309 fn user_written_inline_flag_overrides_smartcase() {
310 let p = FindPattern::compile("(?-i)todo").unwrap();
313 assert_eq!(p.count_matches(lines(&["todo Todo TODO"]).iter()), 1);
314 }
315
316 #[test]
319 fn dollar_is_literal_when_pattern_does_not_capture() {
320 let p = FindPattern::compile("price").unwrap();
321 assert!(!p.has_captures());
322 let (out, n) = replace_all(&p, &lines(&["the price here"]), "$5").unwrap();
323 assert_eq!(n, 1);
324 assert_eq!(out, lines(&["the $5 here"]));
325 }
326
327 #[test]
328 fn dollar_expands_when_pattern_captures() {
329 let p = FindPattern::compile(r"(\w+)-(\w+)").unwrap();
330 assert!(p.has_captures());
331 let (out, _) = replace_all(&p, &lines(&["alpha-beta"]), "$2 $1").unwrap();
332 assert_eq!(out, lines(&["beta alpha"]));
333 }
334
335 #[test]
336 fn a_dollar_naming_no_group_stays_literal_even_when_the_pattern_captures() {
337 let p = FindPattern::compile(r"(Total)").unwrap();
340 let (out, _) = replace_all(&p, &lines(&["Total: 5 due"]), "$1 cost $100").unwrap();
341 assert_eq!(out, lines(&["Total cost $100: 5 due"]));
342 }
343
344 #[test]
345 fn inline_latex_survives_a_capturing_pattern() {
346 let p = FindPattern::compile(r"(area)").unwrap();
347 let (out, _) = replace_all(&p, &lines(&["the area"]), "$1 $x^2$").unwrap();
348 assert_eq!(out, lines(&["the area $x^2$"]));
349 }
350
351 #[test]
352 fn braced_and_named_references_still_expand() {
353 let p = FindPattern::compile(r"(?<word>\w+)-(\d+)").unwrap();
354 let (out, _) = replace_all(&p, &lines(&["ab-12"]), "${word}/$2").unwrap();
355 assert_eq!(out, lines(&["ab/12"]));
356 }
357
358 #[test]
359 fn double_dollar_is_still_an_escape() {
360 let p = FindPattern::compile(r"(x)").unwrap();
361 let (out, _) = replace_all(&p, &lines(&["x"]), "$$1").unwrap();
362 assert_eq!(out, lines(&["$1"]));
363 }
364
365 #[test]
366 fn a_group_that_matched_nothing_expands_to_nothing() {
367 let p = FindPattern::compile(r"a(z*)").unwrap();
370 let (out, _) = replace_all(&p, &lines(&["a"]), "[$1]").unwrap();
371 assert_eq!(out, lines(&["[]"]));
372 }
373
374 #[test]
375 fn non_capturing_group_does_not_enable_expansion() {
376 let p = FindPattern::compile(r"(?:foo)").unwrap();
377 assert!(!p.has_captures());
378 let (out, _) = replace_all(&p, &lines(&["foo"]), "$1").unwrap();
379 assert_eq!(out, lines(&["$1"]));
380 }
381
382 #[test]
385 fn replace_all_rewrites_every_line() {
386 let p = FindPattern::compile("a").unwrap();
387 let (out, n) = replace_all(&p, &lines(&["aa", "b", "a"]), "x").unwrap();
388 assert_eq!(n, 3);
389 assert_eq!(out, lines(&["xx", "b", "x"]));
390 }
391
392 #[test]
393 fn replace_all_reports_none_when_nothing_matches() {
394 let p = FindPattern::compile("zzz").unwrap();
395 assert!(replace_all(&p, &lines(&["abc"]), "x").is_none());
396 }
397
398 #[test]
399 fn empty_replacement_deletes_matches() {
400 let p = FindPattern::compile("todo ").unwrap();
401 let (out, n) = replace_all(&p, &lines(&["todo todo done"]), "").unwrap();
402 assert_eq!(n, 2);
403 assert_eq!(out, lines(&["done"]));
404 }
405
406 #[test]
407 fn replace_all_never_changes_the_line_count() {
408 let p = FindPattern::compile("x").unwrap();
409 let src = lines(&["x", "", "xx", "y"]);
410 let (out, _) = replace_all(&p, &src, "longer").unwrap();
411 assert_eq!(out.len(), src.len());
412 }
413
414 #[test]
417 fn preview_spans_are_in_preview_coordinates_not_buffer_ones() {
418 let p = FindPattern::compile("ab").unwrap();
421 let pv = build_preview(&p, lines(&["ab-ab"]).iter(), "XYZW", None);
422 assert_eq!(pv.lines, lines(&["XYZW-XYZW"]));
423 assert_eq!(pv.spans[0].start, 0);
424 assert_eq!(pv.spans[0].end, 4);
425 assert_eq!(pv.spans[1].start, 5);
426 assert_eq!(pv.spans[1].end, 9);
427 }
428
429 #[test]
430 fn preview_flags_the_current_match_by_buffer_position() {
431 let p = FindPattern::compile("ab").unwrap();
432 let pv = build_preview(&p, lines(&["ab-ab"]).iter(), "X", Some((0, 3)));
434 assert_eq!(
435 pv.spans.iter().map(|s| s.is_current).collect::<Vec<_>>(),
436 vec![false, true]
437 );
438 }
439
440 #[test]
441 fn preview_handles_multibyte_content() {
442 let p = FindPattern::compile("é").unwrap();
443 let pv = build_preview(&p, lines(&["aéb"]).iter(), "ü", None);
444 assert_eq!(pv.lines, lines(&["aüb"]));
445 assert_eq!(pv.spans[0].start, 1);
447 assert_eq!(pv.spans[0].end, 2);
448 }
449
450 #[test]
451 fn preview_with_captures_differs_per_match() {
452 let p = FindPattern::compile(r"(\w)(\d)").unwrap();
454 let pv = build_preview(&p, lines(&["a1 b2"]).iter(), "$2$1", None);
455 assert_eq!(pv.lines, lines(&["1a 2b"]));
456 }
457
458 #[test]
459 fn zero_width_pattern_terminates() {
460 let p = FindPattern::compile(r"\b").unwrap();
461 let pv = build_preview(&p, lines(&["hi there"]).iter(), "|", None);
462 assert_eq!(pv.lines, lines(&["|hi| |there|"]));
463 }
464
465 #[test]
466 fn empty_lines_survive_preview() {
467 let p = FindPattern::compile("x").unwrap();
468 let pv = build_preview(&p, lines(&["", "x", ""]).iter(), "y", None);
469 assert_eq!(pv.lines, lines(&["", "y", ""]));
470 }
471
472 #[test]
473 fn invalid_pattern_reports_error() {
474 assert!(FindPattern::compile("[").is_err());
475 }
476}