Skip to main content

idet_core/
search.rs

1//! Finding and replacing a literal term in a text.
2//!
3//! Matching ignores case, so `Find` locates `find` as well — the behavior a
4//! reader expects from a plain search box. Replacing puts the replacement in
5//! exactly as given, so a case-insensitive match does not carry the case of
6//! what it replaced.
7
8fn chars_match(left: char, right: char) -> bool {
9    left == right || left.to_lowercase().eq(right.to_lowercase())
10}
11
12/// Where `term` occurs in `text`, as character indices in ascending order.
13/// An empty term matches nowhere.
14#[must_use]
15pub fn find_matches(text: &str, term: &str) -> Vec<usize> {
16    let characters: Vec<char> = text.chars().collect();
17    let term_characters: Vec<char> = term.chars().collect();
18    find_matches_in(&characters, &term_characters)
19}
20
21/// [`find_matches`] for callers that already hold both sides as characters,
22/// sparing them a copy per search.
23#[must_use]
24pub fn find_matches_in(text: &[char], term: &[char]) -> Vec<usize> {
25    if term.is_empty() || term.len() > text.len() {
26        return Vec::new();
27    }
28    (0..=text.len() - term.len())
29        .filter(|&index| {
30            text.iter()
31                .skip(index)
32                .zip(term)
33                .all(|(&text_character, &term_character)| {
34                    chars_match(text_character, term_character)
35                })
36        })
37        .collect()
38}
39
40/// Replaces the `length` characters starting at `start` with `replacement`.
41#[must_use]
42pub fn replace_range(text: &str, start: usize, length: usize, replacement: &str) -> String {
43    let characters: Vec<char> = text.chars().collect();
44    let head: String = characters.iter().take(start).collect();
45    let tail: String = characters.iter().skip(start + length).collect();
46    format!("{head}{replacement}{tail}")
47}
48
49/// Replaces every occurrence of `term` with `replacement`, reporting how many
50/// there were.
51///
52/// Occurrences are taken from the original text, so a replacement containing
53/// the term does not feed itself. Where matches overlap — `aa` in `aaaa`
54/// starts at 0, 1 and 2 — each replacement consumes its own characters and the
55/// matches reaching into it are passed over.
56#[must_use]
57pub fn replace_all(text: &str, term: &str, replacement: &str) -> (String, usize) {
58    let characters: Vec<char> = text.chars().collect();
59    let length = term.chars().count();
60    let mut result = String::with_capacity(text.len());
61    let mut index = 0;
62    let mut replaced = 0;
63    for start in find_matches(text, term) {
64        if start < index {
65            continue;
66        }
67        result.extend(characters.iter().skip(index).take(start - index));
68        result.push_str(replacement);
69        index = start + length;
70        replaced += 1;
71    }
72    if replaced == 0 {
73        return (text.to_owned(), 0);
74    }
75    result.extend(characters.iter().skip(index));
76    (result, replaced)
77}
78
79#[cfg(test)]
80mod tests {
81    use super::{find_matches, replace_all, replace_range};
82
83    #[test]
84    fn a_term_is_found_whatever_its_case() {
85        assert_eq!(find_matches("Foo foo FOO", "foo"), vec![0, 4, 8]);
86    }
87
88    #[test]
89    fn an_empty_term_matches_nowhere() {
90        assert!(find_matches("anything", "").is_empty());
91    }
92
93    #[test]
94    fn overlapping_occurrences_are_all_reported() {
95        assert_eq!(find_matches("aaaa", "aa"), vec![0, 1, 2]);
96    }
97
98    #[test]
99    fn replacing_a_range_keeps_both_sides() {
100        assert_eq!(replace_range("hello world", 6, 5, "there"), "hello there");
101    }
102
103    #[test]
104    fn replacing_all_reports_the_count_and_keeps_the_given_case() {
105        let (text, count) = replace_all("Foo and foo", "foo", "bar");
106        assert_eq!(text, "bar and bar");
107        assert_eq!(count, 2);
108    }
109
110    #[test]
111    fn a_replacement_containing_the_term_does_not_feed_itself() {
112        let (text, count) = replace_all("a a", "a", "aa");
113        assert_eq!(text, "aa aa");
114        assert_eq!(count, 2);
115    }
116
117    #[test]
118    fn overlapping_matches_are_replaced_without_reusing_characters() {
119        let (text, count) = replace_all("aaaa", "aa", "b");
120        assert_eq!(text, "bb");
121        assert_eq!(count, 2);
122    }
123
124    #[test]
125    fn an_odd_tail_survives_an_overlapping_replacement() {
126        let (text, count) = replace_all("aaa", "aa", "b");
127        assert_eq!(text, "ba");
128        assert_eq!(count, 1);
129    }
130
131    #[test]
132    fn replacing_all_of_a_missing_term_changes_nothing() {
133        let (text, count) = replace_all("hello", "xyz", "!");
134        assert_eq!(text, "hello");
135        assert_eq!(count, 0);
136    }
137
138    #[test]
139    fn characters_beyond_ascii_are_counted_as_one() {
140        let (text, count) = replace_all("größer größer", "größer", "kleiner");
141        assert_eq!(text, "kleiner kleiner");
142        assert_eq!(count, 2);
143    }
144}