idet-core 0.4.0

Editing logic for text editors, without a frontend
Documentation
//! Finding and replacing a literal term in a text.
//!
//! Matching ignores case, so `Find` locates `find` as well — the behavior a
//! reader expects from a plain search box. Replacing puts the replacement in
//! exactly as given, so a case-insensitive match does not carry the case of
//! what it replaced.

fn chars_match(left: char, right: char) -> bool {
    left == right || left.to_lowercase().eq(right.to_lowercase())
}

/// Where `term` occurs in `text`, as character indices in ascending order.
/// An empty term matches nowhere.
#[must_use]
pub fn find_matches(text: &str, term: &str) -> Vec<usize> {
    let characters: Vec<char> = text.chars().collect();
    let term_characters: Vec<char> = term.chars().collect();
    find_matches_in(&characters, &term_characters)
}

/// [`find_matches`] for callers that already hold both sides as characters,
/// sparing them a copy per search.
#[must_use]
pub fn find_matches_in(text: &[char], term: &[char]) -> Vec<usize> {
    if term.is_empty() || term.len() > text.len() {
        return Vec::new();
    }
    (0..=text.len() - term.len())
        .filter(|&index| {
            text.iter()
                .skip(index)
                .zip(term)
                .all(|(&text_character, &term_character)| {
                    chars_match(text_character, term_character)
                })
        })
        .collect()
}

/// Replaces the `length` characters starting at `start` with `replacement`.
#[must_use]
pub fn replace_range(text: &str, start: usize, length: usize, replacement: &str) -> String {
    let characters: Vec<char> = text.chars().collect();
    let head: String = characters.iter().take(start).collect();
    let tail: String = characters.iter().skip(start + length).collect();
    format!("{head}{replacement}{tail}")
}

/// Replaces every occurrence of `term` with `replacement`, reporting how many
/// there were.
///
/// Occurrences are taken from the original text, so a replacement containing
/// the term does not feed itself. Where matches overlap — `aa` in `aaaa`
/// starts at 0, 1 and 2 — each replacement consumes its own characters and the
/// matches reaching into it are passed over.
#[must_use]
pub fn replace_all(text: &str, term: &str, replacement: &str) -> (String, usize) {
    let characters: Vec<char> = text.chars().collect();
    let length = term.chars().count();
    let mut result = String::with_capacity(text.len());
    let mut index = 0;
    let mut replaced = 0;
    for start in find_matches(text, term) {
        if start < index {
            continue;
        }
        result.extend(characters.iter().skip(index).take(start - index));
        result.push_str(replacement);
        index = start + length;
        replaced += 1;
    }
    if replaced == 0 {
        return (text.to_owned(), 0);
    }
    result.extend(characters.iter().skip(index));
    (result, replaced)
}

#[cfg(test)]
mod tests {
    use super::{find_matches, replace_all, replace_range};

    #[test]
    fn a_term_is_found_whatever_its_case() {
        assert_eq!(find_matches("Foo foo FOO", "foo"), vec![0, 4, 8]);
    }

    #[test]
    fn an_empty_term_matches_nowhere() {
        assert!(find_matches("anything", "").is_empty());
    }

    #[test]
    fn overlapping_occurrences_are_all_reported() {
        assert_eq!(find_matches("aaaa", "aa"), vec![0, 1, 2]);
    }

    #[test]
    fn replacing_a_range_keeps_both_sides() {
        assert_eq!(replace_range("hello world", 6, 5, "there"), "hello there");
    }

    #[test]
    fn replacing_all_reports_the_count_and_keeps_the_given_case() {
        let (text, count) = replace_all("Foo and foo", "foo", "bar");
        assert_eq!(text, "bar and bar");
        assert_eq!(count, 2);
    }

    #[test]
    fn a_replacement_containing_the_term_does_not_feed_itself() {
        let (text, count) = replace_all("a a", "a", "aa");
        assert_eq!(text, "aa aa");
        assert_eq!(count, 2);
    }

    #[test]
    fn overlapping_matches_are_replaced_without_reusing_characters() {
        let (text, count) = replace_all("aaaa", "aa", "b");
        assert_eq!(text, "bb");
        assert_eq!(count, 2);
    }

    #[test]
    fn an_odd_tail_survives_an_overlapping_replacement() {
        let (text, count) = replace_all("aaa", "aa", "b");
        assert_eq!(text, "ba");
        assert_eq!(count, 1);
    }

    #[test]
    fn replacing_all_of_a_missing_term_changes_nothing() {
        let (text, count) = replace_all("hello", "xyz", "!");
        assert_eq!(text, "hello");
        assert_eq!(count, 0);
    }

    #[test]
    fn characters_beyond_ascii_are_counted_as_one() {
        let (text, count) = replace_all("größer größer", "größer", "kleiner");
        assert_eq!(text, "kleiner kleiner");
        assert_eq!(count, 2);
    }
}