use leaf_core::{Doc, Highlight, View};
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum FindField {
Query,
Replacement,
}
pub struct Find {
pub query: String,
pub query_cursor: usize,
pub replacement: Option<String>,
pub replacement_cursor: usize,
pub field: FindField,
pub matches: Vec<(usize, usize)>,
pub hidden: usize,
pub current: usize,
pub origin: usize,
}
impl Find {
pub fn new(replacing: bool, query: String) -> Self {
let query_cursor = query.len();
let seeded = !query.is_empty();
Find {
query,
query_cursor,
replacement: replacing.then(String::new),
replacement_cursor: 0,
field: if replacing && seeded {
FindField::Replacement
} else {
FindField::Query
},
matches: Vec::new(),
hidden: 0,
current: 0,
origin: 0,
}
}
pub fn rows(&self) -> u16 {
if self.replacement.is_some() { 3 } else { 2 }
}
pub fn field_mut(&mut self) -> (&mut String, &mut usize) {
match self.field {
FindField::Query => (&mut self.query, &mut self.query_cursor),
FindField::Replacement => (
self.replacement.get_or_insert_default(),
&mut self.replacement_cursor,
),
}
}
pub fn field(&self) -> (&str, usize) {
match self.field {
FindField::Query => (&self.query, self.query_cursor),
FindField::Replacement => (
self.replacement.as_deref().unwrap_or(""),
self.replacement_cursor,
),
}
}
pub fn set_matches(&mut self, matches: Vec<(usize, usize)>, hidden: usize, anchor: usize) {
self.current = matches
.iter()
.position(|(start, _)| *start >= anchor)
.unwrap_or(0);
self.matches = matches;
self.hidden = hidden;
}
pub fn current_match(&self) -> Option<(usize, usize)> {
self.matches.get(self.current).copied()
}
pub fn step(&mut self, delta: isize) -> Option<(usize, usize)> {
let n = self.matches.len() as isize;
if n == 0 {
return None;
}
self.current = (self.current as isize + delta).rem_euclid(n) as usize;
self.current_match()
}
pub fn highlights(&self) -> Vec<Highlight> {
let mut out = Vec::with_capacity(self.matches.len());
out.extend(self.matches.iter().map(|&(start, end)| Highlight {
start,
end,
id: String::new(),
color: None,
marker: None,
}));
out
}
pub fn caption(&self) -> String {
let hidden = match self.hidden {
0 => String::new(),
n => format!(", {n} hidden"),
};
if self.query.is_empty() {
String::new()
} else if self.matches.is_empty() && self.hidden > 0 {
format!("none shown{hidden}")
} else if self.matches.is_empty() {
"no matches".to_string()
} else {
format!("{} of {}{hidden}", self.current + 1, self.matches.len())
}
}
}
pub fn is_visible(doc: &Doc, start: usize, end: usize) -> bool {
match doc.view {
View::Source => true,
View::Wysiwyg => doc
.vmap
.stop_at_or_after(start)
.is_some_and(|stop| stop < end),
}
}
pub fn find_matches(source: &str, needle: &str) -> Vec<(usize, usize)> {
let needle: Vec<char> = needle.chars().map(fold_case).collect();
if needle.is_empty() {
return Vec::new();
}
let hay: Vec<(usize, char)> = source
.char_indices()
.map(|(at, c)| (at, fold_case(c)))
.collect();
let mut out = Vec::new();
let mut i = 0;
while i + needle.len() <= hay.len() {
if (0..needle.len()).all(|k| hay[i + k].1 == needle[k]) {
let end = hay
.get(i + needle.len())
.map_or(source.len(), |&(at, _)| at);
out.push((hay[i].0, end));
i += needle.len();
} else {
i += 1;
}
}
out
}
fn fold_case(c: char) -> char {
c.to_lowercase().next().unwrap_or(c)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn matching_ignores_case_in_both_directions() {
assert_eq!(find_matches("Hello hello HELLO", "hello").len(), 3);
assert_eq!(find_matches("Hello hello HELLO", "HeLLo").len(), 3);
}
#[test]
fn matches_are_source_byte_ranges_that_slice_back_to_the_text() {
let source = "one two one";
let hits = find_matches(source, "ONE");
assert_eq!(hits, vec![(0, 3), (8, 11)]);
for (s, e) in hits {
assert_eq!(&source[s..e], "one");
}
}
#[test]
fn offsets_survive_multibyte_text_ahead_of_the_match() {
let source = "你好 café Über hello";
let (s, e) = find_matches(source, "hello")[0];
assert_eq!(&source[s..e], "hello");
let (s, e) = find_matches(source, "ÜBER")[0];
assert_eq!(&source[s..e], "Über");
let (s, e) = find_matches(source, "CAFÉ")[0];
assert_eq!(&source[s..e], "café");
}
#[test]
fn overlapping_candidates_are_reported_once_each() {
assert_eq!(find_matches("aaa", "aa"), vec![(0, 2)]);
assert_eq!(find_matches("aaaa", "aa"), vec![(0, 2), (2, 4)]);
}
#[test]
fn an_empty_needle_matches_nothing() {
assert!(find_matches("anything at all", "").is_empty());
}
#[test]
fn a_needle_longer_than_what_is_left_does_not_run_off_the_end() {
assert!(find_matches("short", "much longer needle").is_empty());
assert_eq!(find_matches("abcabc", "abcabc"), vec![(0, 6)]);
}
#[test]
fn the_current_match_is_the_first_at_or_after_the_anchor_and_wraps() {
let mut find = Find::new(false, "one".into());
let matches = find_matches("one two one two one", "one");
assert_eq!(matches.len(), 3);
find.set_matches(matches.clone(), 0, 0);
assert_eq!(find.current, 0);
find.set_matches(matches.clone(), 0, 5);
assert_eq!(find.current, 1, "the first hit at or after the anchor");
find.set_matches(matches, 0, 999);
assert_eq!(find.current, 0);
}
#[test]
fn stepping_wraps_at_both_ends() {
let mut find = Find::new(false, "one".into());
find.set_matches(find_matches("one one one", "one"), 0, 0);
assert_eq!(find.step(-1), find.matches.last().copied(), "back off zero");
assert_eq!(find.step(1), find.matches.first().copied(), "and forward");
}
#[test]
fn the_caption_counts_from_one_and_says_when_there_is_nothing() {
let mut find = Find::new(false, String::new());
assert_eq!(find.caption(), "", "nothing typed yet is not 'no matches'");
find.query = "zzz".into();
assert_eq!(find.caption(), "no matches");
find.set_matches(find_matches("one one", "one"), 0, 0);
assert_eq!(find.caption(), "1 of 2");
find.step(1);
assert_eq!(find.caption(), "2 of 2");
}
#[test]
fn the_caption_says_how_many_matches_the_view_is_not_showing() {
let mut find = Find::new(false, "one".into());
find.set_matches(find_matches("one one", "one"), 1, 0);
assert_eq!(find.caption(), "1 of 2, 1 hidden");
find.set_matches(Vec::new(), 2, 0);
assert_eq!(find.caption(), "none shown, 2 hidden");
find.set_matches(Vec::new(), 0, 0);
assert_eq!(find.caption(), "no matches");
}
#[test]
fn opening_to_replace_over_a_selection_puts_the_keyboard_in_the_replacement() {
assert_eq!(Find::new(true, String::new()).field, FindField::Query);
assert_eq!(Find::new(true, "seed".into()).field, FindField::Replacement);
assert_eq!(Find::new(false, "seed".into()).field, FindField::Query);
}
#[test]
fn a_plain_find_grows_no_replacement_field() {
assert!(Find::new(false, String::new()).replacement.is_none());
assert_eq!(Find::new(false, String::new()).rows(), 2);
assert_eq!(Find::new(true, String::new()).rows(), 3);
}
}