use ropey::Rope;
pub type Match = (usize, usize);
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum CaseMode {
#[default]
Smart,
Sensitive,
Insensitive,
}
impl CaseMode {
fn sensitive_for(self, needle: &str) -> bool {
match self {
CaseMode::Sensitive => true,
CaseMode::Insensitive => false,
CaseMode::Smart => needle.chars().any(char::is_uppercase),
}
}
}
pub fn find_all(text: &Rope, needle: &str, mode: CaseMode) -> Vec<Match> {
if needle.is_empty() {
return Vec::new();
}
let sensitive = mode.sensitive_for(needle);
let haystack = text.to_string();
let (haystack, needle) = if sensitive {
(haystack, needle.to_string())
} else {
(haystack.to_lowercase(), needle.to_lowercase())
};
let mut matches = Vec::new();
let needle_chars = needle.chars().count();
for (byte, _) in haystack.match_indices(&needle) {
let start = haystack[..byte].chars().count();
matches.push((start, start + needle_chars));
}
matches
}
pub fn next_from(matches: &[Match], from: usize) -> Option<usize> {
if matches.is_empty() {
return None;
}
Some(matches.iter().position(|(start, _)| *start >= from).unwrap_or(0))
}
pub fn prev_from(matches: &[Match], from: usize) -> Option<usize> {
if matches.is_empty() {
return None;
}
Some(matches.iter().rposition(|(start, _)| *start < from).unwrap_or(matches.len() - 1))
}
#[cfg(test)]
mod tests {
use super::*;
fn rope(s: &str) -> Rope {
Rope::from_str(s)
}
#[test]
fn every_occurrence_is_found_in_order() {
let text = rope("one two one three one");
let found = find_all(&text, "one", CaseMode::Sensitive);
assert_eq!(found, [(0, 3), (8, 11), (18, 21)]);
}
#[test]
fn nothing_matches_an_empty_query() {
assert!(find_all(&rope("anything"), "", CaseMode::Smart).is_empty());
}
#[test]
fn a_missing_needle_finds_nothing() {
assert!(find_all(&rope("abc"), "zzz", CaseMode::Smart).is_empty());
}
#[test]
fn smart_case_is_insensitive_until_you_type_a_capital() {
let text = rope("Error error ERROR");
assert_eq!(find_all(&text, "error", CaseMode::Smart).len(), 3, "all three");
assert_eq!(find_all(&text, "Error", CaseMode::Smart), [(0, 5)], "the capital narrows it");
}
#[test]
fn explicit_modes_override_the_smart_default() {
let text = rope("Error error");
assert_eq!(find_all(&text, "Error", CaseMode::Insensitive).len(), 2);
assert_eq!(find_all(&text, "error", CaseMode::Sensitive).len(), 1);
}
#[test]
fn matches_are_char_offsets_not_byte_offsets() {
let text = rope("héllo world héllo");
assert_eq!(find_all(&text, "world", CaseMode::Sensitive), [(6, 11)]);
}
#[test]
fn matches_span_lines_correctly() {
let text = rope("first\nsecond\nfirst\n");
assert_eq!(find_all(&text, "first", CaseMode::Sensitive), [(0, 5), (13, 18)]);
}
#[test]
fn overlapping_candidates_are_reported_without_overlap() {
assert_eq!(find_all(&rope("aaaa"), "aa", CaseMode::Sensitive), [(0, 2), (2, 4)]);
}
#[test]
fn next_finds_the_match_at_or_after_the_cursor() {
let matches = [(0, 3), (8, 11), (18, 21)];
assert_eq!(next_from(&matches, 0), Some(0));
assert_eq!(next_from(&matches, 1), Some(1));
assert_eq!(next_from(&matches, 8), Some(1), "a cursor sitting on one stays on it");
assert_eq!(next_from(&matches, 12), Some(2));
}
#[test]
fn next_wraps_past_the_last_match() {
let matches = [(0, 3), (8, 11)];
assert_eq!(next_from(&matches, 99), Some(0), "back to the top");
}
#[test]
fn prev_finds_the_last_match_starting_before_the_offset_and_wraps() {
let matches = [(0, 3), (8, 11), (18, 21)];
assert_eq!(prev_from(&matches, 18), Some(1));
assert_eq!(prev_from(&matches, 8), Some(0));
assert_eq!(prev_from(&matches, 0), Some(2), "wraps to the end");
}
#[test]
fn stepping_backwards_repeatedly_walks_the_matches() {
let matches = [(0, 3), (8, 11), (18, 21)];
let mut at = 2;
at = prev_from(&matches, matches[at].0).unwrap();
assert_eq!(at, 1);
at = prev_from(&matches, matches[at].0).unwrap();
assert_eq!(at, 0);
at = prev_from(&matches, matches[at].0).unwrap();
assert_eq!(at, 2, "and wraps");
}
#[test]
fn navigating_an_empty_result_set_goes_nowhere() {
assert_eq!(next_from(&[], 0), None);
assert_eq!(prev_from(&[], 0), None);
}
}