Skip to main content

kimun_notes/components/text_editor/
find_replace.rs

1//! The pure half of find-and-replace: compiling a **find pattern**, counting
2//! its matches, expanding a replacement, and building the substituted lines a
3//! **replace preview** draws.
4//!
5//! Nothing here touches a `TextArea`, a `Frame`, or the editor — it is
6//! `&[String]` in, values out, so the semantics that are easy to get wrong
7//! (smartcase, capture gating, span remapping) are testable with literals.
8//!
9//! Matching is per line, because that is what the textarea's search engine
10//! does: a pattern can never span a newline, and `^`/`$` anchor per line.
11
12use regex::Regex;
13
14/// A compiled **find pattern**, plus the case decision that produced it.
15#[derive(Debug, Clone)]
16pub struct FindPattern {
17    re: Regex,
18    /// True when the pattern was compiled case-sensitively — i.e. the user
19    /// typed at least one uppercase character. Surfaced in the find bar so
20    /// smartcase is never a silent decision.
21    case_sensitive: bool,
22    /// True when the pattern captures, which is what gates `$1` expansion in
23    /// the replacement.
24    has_captures: bool,
25}
26
27impl FindPattern {
28    /// Compile `query` under **smartcase**: an all-lowercase query matches any
29    /// case, any uppercase makes it exact.
30    ///
31    /// The `(?i)` is prepended rather than set via `RegexBuilder` so a
32    /// user-written inline flag (`(?-i)`, or a scoped `(?i:…)`) still wins —
33    /// later inline flags override earlier ones.
34    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        // `captures_len` counts the implicit whole-match group, so >1 means the
42        // pattern has at least one real capture. Non-capturing `(?:…)` groups
43        // do not count, which is the correct reading — they group, they do not
44        // capture.
45        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    /// Total matches across every line. Cheap enough to run per keystroke:
66    /// this is `find_iter` over strings the editor already holds, not the
67    /// cell-by-cell row reconstruction that `paint_viewport_extras` avoids.
68    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    /// Every match as a `(row, start_char, end_char)` span in **logical**
74    /// buffer coordinates.
75    ///
76    /// The same coordinates `count_matches` and the textarea's stepping use, so
77    /// highlighting built from these cannot disagree with them about what
78    /// matched — the way matching against rendered cell text does the moment a
79    /// row contains concealed markdown.
80    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    /// Expand `replacement` for one match.
97    ///
98    /// Capture syntax (`$1`, `${name}`) is honoured **only when the pattern
99    /// captures**. Otherwise the replacement is literal, so a `$`
100    /// in ordinary note content — a price, inline LaTeX — survives instead of
101    /// silently expanding to the empty string.
102    ///
103    /// Even when the pattern captures, a `$` that names a group which does not
104    /// exist stays literal. `Captures::expand` would erase it: in a note
105    /// `$100` parses as group 100 and `$x^2$` as a group named `x`, and both
106    /// expand to nothing. Gating on whether the pattern captures at all is not
107    /// enough — a user who writes a capture group is exactly the user who then
108    /// writes `$1 costs $100`.
109    pub fn expand(&self, caps: &regex::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            // `$$` is regex-crate syntax for a literal `$`; keep honouring it.
119            if let Some(after) = tail.strip_prefix("$$") {
120                out.push('$');
121                rest = after;
122                continue;
123            }
124            // Ask the crate to expand this one reference in isolation. If it
125            // resolves to nothing AND the group does not exist, the reference
126            // was never a reference — emit it as typed.
127            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
143/// Byte length of the capture reference starting at `s[0] == '$'`, mirroring
144/// the `regex` crate's own grammar: `${...}` up to the brace, otherwise the
145/// run of `[0-9A-Za-z_]` after the sigil. A bare `$` with nothing referenceable
146/// after it is length 1.
147fn 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
161/// Whether `reference` (a single `$…` capture reference) names a group that
162/// actually exists in `caps` — the difference between "this group matched
163/// nothing" and "this was never a group".
164fn group_exists(caps: &regex::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/// One match rewritten inside a previewed line, in that line's **preview**
179/// coordinates (char columns), not the buffer's.
180#[derive(Debug, Clone, Copy, PartialEq, Eq)]
181pub struct PreviewSpan {
182    pub row: usize,
183    /// Char column where the replacement text starts.
184    pub start: usize,
185    /// Char column just past the replacement text.
186    pub end: usize,
187    /// Whether this is the **current match** — the one `Enter` rewrites, as
188    /// against the ones only `Ctrl+A` reaches.
189    pub is_current: bool,
190}
191
192/// The result of substituting every match into a copy of the buffer.
193#[derive(Debug, Clone, PartialEq, Eq)]
194pub struct Preview {
195    pub lines: Vec<String>,
196    pub spans: Vec<PreviewSpan>,
197}
198
199/// Build the **replace preview**: every match replaced, plus where each
200/// replacement landed so the renderer can colour it.
201///
202/// `current` is the buffer-coordinate `(row, char_col)` start of the current
203/// match, so the span covering it can be flagged. It is passed in buffer
204/// coordinates because that is what the editor knows; the remap to preview
205/// coordinates happens here, where the length delta is being accumulated
206/// anyway.
207///
208/// The line *count* never changes — the pattern cannot span a newline and the
209/// replace field is single-line — which is why callers may keep their scroll
210/// offset and row indices across a preview.
211pub 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        // Byte cursor into the ORIGINAL line; char cursor into the REBUILT one.
224        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            // A zero-width match (e.g. `\b`, `x*`) would otherwise loop
249            // forever on the same offset; `captures_iter` already advances,
250            // but the byte cursor must not go backwards.
251            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
266/// Rewrite every match in `lines`, returning the new lines and how many
267/// matches were rewritten. The **replace all** primitive.
268///
269/// Returns `None` when nothing matched, so callers can distinguish "no work"
270/// from "rewrote zero characters" without re-counting.
271pub 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    // ── smartcase ──────────────────────────────────────────────────────────
293
294    #[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        // Lowercase query, so smartcase prepends `(?i)` — the user's `(?-i)`
311        // comes later in the pattern and must win.
312        let p = FindPattern::compile("(?-i)todo").unwrap();
313        assert_eq!(p.count_matches(lines(&["todo Todo TODO"]).iter()), 1);
314    }
315
316    // ── capture gating ──────────────────────────────────────────
317
318    #[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        // The capture gate alone protects only patterns with no groups. A user
338        // who writes a group is exactly the user who then writes a price.
339        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        // Distinct from a group that does not exist: this one is real and
368        // simply matched the empty string, so erasing it is correct.
369        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    // ── replace all ────────────────────────────────────────────────────────
383
384    #[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    // ── preview spans ──────────────────────────────────────────────────────
415
416    #[test]
417    fn preview_spans_are_in_preview_coordinates_not_buffer_ones() {
418        // "ab" -> "XYZW" shifts everything after the first match right by 2,
419        // so the second span must not be reported at its buffer column.
420        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        // Second match starts at buffer char col 3 of "ab-ab".
433        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        // Char columns, not byte offsets.
446        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        // The case that makes previewing only the current match misleading.
453        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}