use crate::index::TextIndex;
use crate::unicode::{is_decimal_digit, lower_string};
use std::ops::Range;
const NON_BREAKING_SPACE: char = '\u{00A0}';
const HYPHEN_SENTINEL: char = '\u{00AD}';
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct FindOptions {
pub match_case: bool,
pub match_whole_word: bool,
pub consecutive: bool,
}
#[must_use]
pub fn splits_needle(ch: char) -> bool {
let code = u32::from(ch);
!(code < 255
|| (0x0600..=0x06FF).contains(&code) || (0xFE70..=0xFEFF).contains(&code) || (0xFB50..=0xFDFF).contains(&code) || (0x0400..=0x04FF).contains(&code) || (0x0500..=0x052F).contains(&code) || (0xA640..=0xA69F).contains(&code) || (0x2DE0..=0x2DFF).contains(&code) || code == 0x2113 || (0x2000..=0x206F).contains(&code)) }
fn is_separator(ch: char) -> bool {
ch == '\n' || ch == ' ' || ch == '\r' || ch == NON_BREAKING_SPACE
}
fn sub_string(needle: &[char], index: usize) -> Option<Vec<char>> {
let mut at = 0usize;
for _ in 0..index {
let space = needle.get(at..)?.iter().position(|ch| *ch == ' ')?;
at += space + 1;
while needle.get(at) == Some(&' ') {
at += 1;
}
}
let rest = needle.get(at..)?;
let end = rest.iter().position(|ch| *ch == ' ').unwrap_or(rest.len());
rest.get(..end).map(<[char]>::to_vec)
}
#[must_use]
pub fn split_needle(needle: &str) -> Vec<String> {
let chars: Vec<char> = needle.chars().collect();
if chars.iter().all(|ch| *ch == ' ') {
return vec![needle.to_owned()];
}
let mut out: Vec<String> = Vec::new();
let mut index = 0usize;
while index <= chars.len() {
let Some(mut word) = sub_string(&chars, index) else {
break;
};
if word.is_empty() {
out.push(String::new());
index += 1;
continue;
}
let mut pos = 0usize;
while pos < word.len() {
let Some(¤t) = word.get(pos) else { break };
if splits_needle(current) {
if pos > 0 && current == '\u{2019}' {
pos += 1;
continue;
}
if pos > 0 {
out.push(word.get(..pos).unwrap_or_default().iter().collect());
}
out.push(current.to_string());
if pos == word.len() - 1 {
word.clear();
break;
}
word = word.get(pos + 1..).unwrap_or_default().to_vec();
pos = 0;
continue;
}
pos += 1;
}
if !word.is_empty() {
out.push(word.iter().collect());
}
index += 1;
}
out
}
#[must_use]
pub fn is_whole_word(text: &[char], start: usize, end: usize) -> bool {
if start > end {
return false;
}
let count = end - start + 1;
if count == 1
&& text
.get(start)
.copied()
.is_some_and(|ch| u32::from(ch) > 255)
{
return true;
}
let at = |index: usize| -> u32 { text.get(index).copied().map_or(0, u32::from) };
let left = if start >= 1 { at(start - 1) } else { 0 };
let right = if start + count < text.len() {
at(start + count)
} else {
0
};
let letterish = |ch: u32| {
(ch > u32::from(b'A') && ch < u32::from(b'a'))
|| (ch > u32::from(b'a') && ch < u32::from(b'z'))
|| (ch > 0xFB00 && ch < 0xFB06)
|| is_decimal_digit(ch)
};
if letterish(left) || letterish(right) {
return false;
}
let outside_ascii_letters = |ch: u32| {
(u32::from(b'A') > ch || ch > u32::from(b'Z'))
&& (u32::from(b'a') > ch || ch > u32::from(b'z'))
};
if !(outside_ascii_letters(left) && outside_ascii_letters(right)) {
return false;
}
if is_decimal_digit(left) && is_decimal_digit(at(start)) {
return false;
}
if is_decimal_digit(right) && is_decimal_digit(at(end)) {
return false;
}
true
}
#[derive(Debug, Clone)]
pub struct Search<'a> {
haystack: Vec<char>,
origins: Vec<usize>,
needles: Vec<Vec<char>>,
options: FindOptions,
next_start: Option<usize>,
marker: std::marker::PhantomData<&'a ()>,
}
#[must_use]
pub fn search<'a>(text: &str, needle: &str, options: FindOptions) -> Search<'a> {
let fold = |value: &str| -> String {
if options.match_case {
value.to_owned()
} else {
lower_string(value)
}
};
let folded: Vec<char> = fold(text).chars().collect();
let mut haystack: Vec<char> = Vec::with_capacity(folded.len());
let mut origins: Vec<usize> = Vec::with_capacity(folded.len());
let mut dropped = false;
for (at, &ch) in folded.iter().enumerate() {
if ch == HYPHEN_SENTINEL {
dropped = true;
continue;
}
haystack.push(ch);
origins.push(at);
}
if !dropped {
origins.clear();
}
let needles: Vec<Vec<char>> = split_needle(&fold(needle))
.into_iter()
.map(|word| word.chars().collect())
.collect();
Search {
next_start: (!haystack.is_empty()).then_some(0),
haystack,
origins,
needles,
options,
marker: std::marker::PhantomData,
}
}
impl Iterator for Search<'_> {
type Item = Range<TextIndex>;
fn next(&mut self) -> Option<Range<TextIndex>> {
let start = self.next_start?;
let (result_start, result_end) = self.scan(start)?;
self.next_start = Some(if self.options.consecutive {
result_start + 1
} else {
result_end + 1
});
Some(TextIndex::new(self.origin(result_start))..TextIndex::new(self.origin(result_end) + 1))
}
}
impl Search<'_> {
fn origin(&self, index: usize) -> usize {
self.origins.get(index).copied().unwrap_or(index)
}
fn scan(&mut self, from: usize) -> Option<(usize, usize)> {
let length = self.haystack.len();
if self.haystack.is_empty() || self.needles.is_empty() || from >= length {
self.next_start = None;
return None;
}
let mut start = from;
let mut result_pos = 0usize;
let mut result_start = 0usize;
let mut space_start = false;
let mut word = 0usize;
let mut restarts_left = length + 1;
while word < self.needles.len() {
let Some(needle) = self.needles.get(word) else {
break;
};
if needle.is_empty() {
if word == self.needles.len() - 1 {
let Some(&ch) = self.haystack.get(start) else {
self.next_start = None;
return None;
};
if is_separator(ch) {
result_pos = start + 1;
break;
}
restarts_left = restarts_left.checked_sub(1)?;
word = 0;
continue;
}
if word == 0 {
space_start = true;
}
word += 1;
continue;
}
let Some(found) = find_from(&self.haystack, needle, start) else {
self.next_start = None;
return None;
};
result_pos = found;
let end_index = found + needle.len() - 1;
if word == 0 {
result_start = found;
}
let mut matched = true;
if word != 0 && !space_start {
let current = needle.first().copied().unwrap_or('\0');
let last = self
.needles
.get(word - 1)
.and_then(|previous| previous.last().copied())
.unwrap_or('\0');
if start == found && !(splits_needle(last) || splits_needle(current)) {
matched = false;
}
for offset in start..found {
if !self.haystack.get(offset).copied().is_some_and(is_separator) {
matched = false;
break;
}
}
} else if space_start && found > 0 {
let before = self.haystack.get(found - 1).copied().unwrap_or('\0');
if is_separator(before) {
result_start = found - 1;
} else {
matched = false;
result_start = found;
}
}
if self.options.match_whole_word && matched {
matched = is_whole_word(&self.haystack, found, end_index);
}
if matched {
start = end_index + 1;
word += 1;
} else {
restarts_left = restarts_left.checked_sub(1)?;
let index = usize::from(space_start);
let advance = self.needles.get(index).map_or(0, Vec::len);
start = result_start + advance;
if start >= length {
self.next_start = None;
return None;
}
word = 0;
}
}
let last_len = self.needles.last().map_or(0, Vec::len);
let result_end = result_pos + last_len;
let result_end = result_end.checked_sub(1)?;
Some((result_start, result_end))
}
}
fn find_from(haystack: &[char], needle: &[char], from: usize) -> Option<usize> {
if needle.is_empty() || from > haystack.len() {
return None;
}
let last = haystack.len().checked_sub(needle.len())?;
(from..=last).find(|start| haystack.get(*start..start + needle.len()) == Some(needle))
}
#[cfg(test)]
mod tests {
#![allow(
clippy::float_cmp,
clippy::indexing_slicing,
clippy::unreadable_literal,
clippy::cast_precision_loss,
clippy::cast_possible_truncation,
reason = "test fixtures quote oracle vectors verbatim and compare exactly"
)]
use super::*;
fn ranges(text: &str, needle: &str, options: FindOptions) -> Vec<Range<usize>> {
search(text, needle, options)
.map(|hit| hit.start.get()..hit.end.get())
.collect()
}
const HELLO: &str = "Hello, world!\r\nGoodbye, world!";
#[test]
fn a_word_split_across_a_line_break_is_found_joined() {
let text = "a note\u{00AD}book here";
let hits: Vec<_> = search(text, "notebook", FindOptions::default()).collect();
assert_eq!(hits.len(), 1, "the joined word is found");
let chars: Vec<char> = text.chars().collect();
let hit = hits[0].start.get()..hits[0].end.get();
let slice: String = chars[hit.clone()].iter().collect();
assert_eq!(slice, "note\u{00AD}book");
assert_eq!(hit, 2..11);
assert_eq!(
search(text, "note book", FindOptions::default()).count(),
0,
"the sentinel is not a space"
);
}
#[test]
fn splitting_is_by_script_not_by_alphabet() {
assert!(!splits_needle('a'));
assert!(!splits_needle('\u{00FE}'));
assert!(!splits_needle('\u{0410}')); assert!(!splits_needle('\u{0627}')); assert!(!splits_needle('\u{2019}')); assert!(!splits_needle('\u{2113}')); assert!(splits_needle('\u{4E00}')); assert!(splits_needle('\u{AC00}')); assert!(splits_needle('\u{0905}')); assert!(splits_needle('\u{00FF}'));
}
#[test]
fn an_all_space_needle_is_not_split() {
assert_eq!(split_needle(" "), [" "]);
assert_eq!(split_needle(" "), [" "]);
assert_eq!(split_needle(""), [""]);
}
#[test]
fn a_needle_splits_at_spaces_and_at_standalone_units() {
assert_eq!(split_needle("ab cd"), ["ab", "cd"]);
assert_eq!(split_needle("ab cd"), ["ab", "cd"]);
assert_eq!(split_needle("a\u{4E00}b"), ["a", "\u{4E00}", "b"]);
assert_eq!(split_needle("\u{4E00}\u{4E8C}"), ["\u{4E00}", "\u{4E8C}"]);
assert_eq!(split_needle("don\u{2019}t"), ["don\u{2019}t"]);
}
#[test]
fn a_trailing_space_leaves_an_empty_sub_needle() {
assert_eq!(split_needle("ld! "), ["ld!", ""]);
assert_eq!(split_needle(" Good"), ["", "Good"]);
}
#[test]
fn substring_extraction_walks_space_delimited_tokens() {
let chars: Vec<char> = "a b".chars().collect();
assert_eq!(sub_string(&chars, 0), Some(vec!['a']));
assert_eq!(sub_string(&chars, 1), Some(vec!['b']));
assert_eq!(sub_string(&chars, 2), None);
let chars: Vec<char> = "a b".chars().collect();
assert_eq!(sub_string(&chars, 1), Some(vec!['b']));
let chars: Vec<char> = "a ".chars().collect();
assert_eq!(sub_string(&chars, 1), Some(vec![]));
assert_eq!(sub_string(&chars, 2), None);
}
#[test]
fn searching_finds_every_occurrence() {
assert_eq!(ranges(HELLO, "nope", FindOptions::default()), []);
assert_eq!(
ranges(HELLO, "world", FindOptions::default()),
[7..12, 24..29]
);
}
#[test]
fn the_default_is_case_insensitive() {
assert_eq!(
ranges(HELLO, "WORLD", FindOptions::default()),
[7..12, 24..29]
);
let cased = FindOptions {
match_case: true,
..FindOptions::default()
};
assert_eq!(ranges(HELLO, "WORLD", cased), []);
assert_eq!(ranges(HELLO, "world", cased), [7..12, 24..29]);
}
#[test]
fn whole_word_rejects_a_substring_match() {
let whole = FindOptions {
match_whole_word: true,
..FindOptions::default()
};
assert_eq!(
ranges(HELLO, "orld", FindOptions::default()),
[8..12, 25..29]
);
assert_eq!(ranges(HELLO, "orld", whole), []);
assert_eq!(ranges(HELLO, "world", whole), [7..12, 24..29]);
}
#[test]
fn consecutive_reports_overlapping_matches() {
let text = "aaaaaaaaaa";
assert_eq!(ranges(text, "aaaa", FindOptions::default()), [0..4, 4..8]);
let consecutive = FindOptions {
consecutive: true,
..FindOptions::default()
};
assert_eq!(
ranges(text, "aaaa", consecutive),
[0..4, 1..5, 2..6, 3..7, 4..8, 5..9, 6..10]
);
}
#[test]
fn a_needle_spanning_a_line_break_matches_through_it() {
assert_eq!(ranges(HELLO, "ld! G", FindOptions::default()), vec![10..16]);
}
#[test]
fn a_leading_space_in_the_needle_matches_the_separator_before_the_word() {
assert_eq!(ranges(HELLO, " Good", FindOptions::default()), vec![14..19]);
}
#[test]
fn a_trailing_space_in_the_needle_matches_the_separator_after_the_word() {
assert_eq!(ranges(HELLO, "ld! ", FindOptions::default()), vec![10..14]);
}
#[test]
fn searching_an_empty_page_finds_nothing() {
assert_eq!(ranges("", "anything", FindOptions::default()), []);
assert_eq!(ranges("text", "", FindOptions::default()), []);
}
#[test]
fn whole_word_boundaries_use_the_two_overlapping_tests() {
let text: Vec<char> = "a-b".chars().collect();
assert!(!is_whole_word(&text, 1, 1));
let spaced: Vec<char> = " - ".chars().collect();
assert!(is_whole_word(&spaced, 1, 1));
let digits: Vec<char> = "12".chars().collect();
assert!(!is_whole_word(&digits, 1, 1));
let capital: Vec<char> = "Zx".chars().collect();
assert!(!is_whole_word(&capital, 1, 1));
assert!(!is_whole_word(&digits, 1, 0));
let cjk: Vec<char> = "a\u{4E00}b".chars().collect();
assert!(is_whole_word(&cjk, 1, 1));
}
#[test]
fn a_restarting_search_terminates() {
let found = ranges("aaaa", " zzz", FindOptions::default());
assert!(found.is_empty());
let found = ranges("aaaa", " ", FindOptions::default());
assert!(found.is_empty(), "{found:?}");
}
}