use std::cmp::Ordering;
use std::sync::OnceLock;
use unicode_segmentation::UnicodeSegmentation;
use crate::folding::{self};
pub use crate::folding::{FoldLocale, FoldSpec};
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)]
pub struct MatchOptions {
pub case_sensitive: bool,
pub diacritic_sensitive: bool,
pub whole_word: bool,
pub locale: FoldLocale,
}
impl MatchOptions {
pub fn fold_spec(&self) -> FoldSpec {
FoldSpec {
case_sensitive: self.case_sensitive,
diacritic_sensitive: self.diacritic_sensitive,
locale: self.locale,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Match {
pub char_start: usize,
pub char_len: usize,
}
pub fn find_all(haystack: &str, needle: &str, options: &MatchOptions) -> Vec<Match> {
FoldedText::new(haystack, &options.fold_spec()).find_all(needle, options.whole_word)
}
pub struct FoldedText {
source: String,
folded: Folded,
spec: FoldSpec,
boundaries: OnceLock<Vec<u32>>,
}
impl FoldedText {
pub fn new(haystack: &str, spec: &FoldSpec) -> Self {
Self {
source: haystack.to_string(),
folded: Folded::new(haystack, spec),
spec: *spec,
boundaries: OnceLock::new(),
}
}
pub fn source(&self) -> &str {
&self.source
}
pub fn find_all(&self, needle: &str, whole_word: bool) -> Vec<Match> {
if needle.is_empty() || self.folded.text.is_empty() {
return Vec::new();
}
let folded_needle = fold_query(needle, &self.spec);
if folded_needle.is_empty() {
return Vec::new();
}
if !whole_word {
return self.folded.matches(&folded_needle);
}
let boundaries = self
.boundaries
.get_or_init(|| word_boundaries(&self.source));
let arabic = starts_with_arabic(&folded_needle);
self.folded
.scan(&folded_needle)
.filter(|(folded_start, _, m)| {
let ends_on_a_word = is_boundary(boundaries, m.char_start + m.char_len);
let starts_on_a_word = is_boundary(boundaries, m.char_start)
|| (arabic && after_arabic_proclitic(&self.folded, boundaries, *folded_start));
starts_on_a_word && ends_on_a_word
})
.map(|(_, _, m)| m)
.collect()
}
pub fn prepare_word_boundaries(&self) {
self.boundaries
.get_or_init(|| word_boundaries(&self.source));
}
pub fn heap_size(&self) -> usize {
self.source.capacity()
+ self.folded.text.capacity()
+ self.folded.origin.capacity() * 4
+ self.folded.byte_of_char.capacity() * 4
+ self.boundaries.get().map_or(0, |b| b.capacity() * 4)
}
}
fn fold_query(needle: &str, spec: &FoldSpec) -> String {
let mut out = String::with_capacity(needle.len());
let mut chars = needle.chars().peekable();
while let Some(c) = chars.next() {
let consumed = folding::fold_char(c, chars.peek().copied(), spec, &mut |g| out.push(g));
if consumed == 2 {
chars.next();
}
}
out
}
pub(crate) struct Folded {
text: String,
origin: Vec<u32>,
byte_of_char: Vec<u32>,
}
impl Folded {
pub(crate) fn new_for(text: &str, options: &MatchOptions) -> Self {
Self::new(text, &options.fold_spec())
}
pub(crate) fn text(&self) -> &str {
&self.text
}
fn new(text: &str, spec: &FoldSpec) -> Self {
let mut folded = String::with_capacity(text.len());
let mut origin: Vec<u32> = Vec::with_capacity(text.len());
let mut byte_of_char: Vec<u32> = Vec::with_capacity(text.len());
let mut chars = text.chars().enumerate().peekable();
let mut source_len = 0usize;
while let Some((i, c)) = chars.next() {
let next = chars.peek().map(|&(_, c)| c);
let consumed = folding::fold_char(c, next, spec, &mut |g| {
byte_of_char.push(folded.len() as u32);
origin.push(i as u32);
folded.push(g);
});
if consumed == 2 {
chars.next();
}
source_len = i + consumed;
}
byte_of_char.push(folded.len() as u32);
origin.push(source_len as u32);
Self {
text: folded,
origin,
byte_of_char,
}
}
pub(crate) fn char_of_byte(&self, byte: usize) -> Option<usize> {
self.byte_of_char.binary_search(&(byte as u32)).ok()
}
pub(crate) fn to_source_match(&self, folded_start: usize, folded_end: usize) -> Option<Match> {
let from = *self.origin.get(folded_start)?;
let to = *self.origin.get(folded_end)?;
if folded_start > 0 && self.origin[folded_start - 1] == from {
return None;
}
if folded_end > 0 && self.origin[folded_end - 1] == to {
return None;
}
Some(Match {
char_start: from as usize,
char_len: to.saturating_sub(from) as usize,
})
}
fn matches(&self, folded_needle: &str) -> Vec<Match> {
self.scan(folded_needle).map(|(_, _, m)| m).collect()
}
fn scan<'a>(
&'a self,
folded_needle: &'a str,
) -> impl Iterator<Item = (usize, usize, Match)> + 'a {
let needle_chars = folded_needle.chars().count();
self.text
.match_indices(folded_needle)
.filter_map(move |(byte, _)| {
let folded_start = self.char_of_byte(byte)?;
let folded_end = folded_start + needle_chars;
let m = self.to_source_match(folded_start, folded_end)?;
Some((folded_start, folded_end, m))
})
}
}
fn is_apostrophe(ch: char) -> bool {
matches!(ch, '\u{0027}' | '\u{2019}' | '\u{02BC}')
}
fn is_arabic(ch: char) -> bool {
matches!(ch, '\u{0600}'..='\u{06FF}' | '\u{0750}'..='\u{077F}' | '\u{08A0}'..='\u{08FF}')
}
fn starts_with_arabic(folded_needle: &str) -> bool {
folded_needle.chars().next().is_some_and(is_arabic)
}
const ARABIC_PROCLITICS: &[&str] = &["ال", "وال", "بال", "فال", "كال", "لل"];
fn after_arabic_proclitic(folded: &Folded, boundaries: &[u32], folded_start: usize) -> bool {
ARABIC_PROCLITICS.iter().any(|proclitic| {
let len = proclitic.chars().count();
let Some(prefix_start) = folded_start.checked_sub(len) else {
return false;
};
let from = folded.byte_of_char[prefix_start] as usize;
let to = folded.byte_of_char[folded_start] as usize;
if &folded.text[from..to] != *proclitic {
return false;
}
is_boundary(boundaries, folded.origin[prefix_start] as usize)
})
}
pub(crate) fn word_boundaries(text: &str) -> Vec<u32> {
let mut out: Vec<u32> = Vec::new();
out.push(0);
let mut words = text.unicode_word_indices().peekable();
let mut char_idx: u32 = 0;
for (byte_idx, ch) in text.char_indices() {
while let Some(&(word_byte, word)) = words.peek() {
match word_byte.cmp(&byte_idx) {
Ordering::Less => {
words.next();
}
Ordering::Equal => {
out.push(char_idx);
out.push(char_idx + word.chars().count() as u32);
words.next();
}
Ordering::Greater => break,
}
}
if is_apostrophe(ch) {
out.push(char_idx);
out.push(char_idx + 1);
}
char_idx += 1;
}
out.push(char_idx);
out.sort_unstable();
out.dedup();
out
}
pub(crate) fn is_boundary(boundaries: &[u32], char_idx: usize) -> bool {
boundaries.binary_search(&(char_idx as u32)).is_ok()
}
pub fn preserve_case(matched: &str, replacement: &str, locale: FoldLocale) -> String {
let letters: Vec<char> = matched.chars().filter(|c| c.is_alphabetic()).collect();
let Some(&first) = letters.first() else {
return replacement.to_string();
};
if letters.len() > 1 && letters.iter().all(|c| c.is_uppercase()) {
let mut out = String::with_capacity(replacement.len());
for c in replacement.chars() {
folding::to_upper(c, locale, &mut out);
}
return out;
}
if first.is_uppercase() && letters[1..].iter().all(|c| c.is_lowercase()) {
let mut out = String::with_capacity(replacement.len());
let mut chars = replacement.chars();
if let Some(c) = chars.next() {
folding::to_upper(c, locale, &mut out);
}
out.extend(chars);
return out;
}
replacement.to_string()
}
#[cfg(test)]
mod tests {
use super::*;
fn opts(case_sensitive: bool, whole_word: bool) -> MatchOptions {
MatchOptions {
case_sensitive,
whole_word,
..MatchOptions::default()
}
}
fn matched(haystack: &str, m: Match) -> String {
haystack
.chars()
.skip(m.char_start)
.take(m.char_len)
.collect()
}
#[test]
fn an_offset_from_folded_text_is_reported_in_the_source() {
let text = "İİİ ipsum dolor";
assert!(
text.to_lowercase().chars().count() > text.chars().count(),
"this fixture must actually grow when lowercased, or it proves nothing"
);
let hits = find_all(text, "ipsum", &opts(false, false));
assert_eq!(hits.len(), 1);
assert_eq!(
hits[0].char_start, 4,
"the offset must be valid in the SOURCE"
);
assert_eq!(matched(text, hits[0]), "ipsum");
}
#[test]
fn a_query_cannot_match_half_of_a_folded_letter() {
let text = "Straße";
let hits = find_all(text, "s", &opts(false, false));
assert_eq!(
hits.len(),
1,
"only the leading S — never half of the ß, which folds to two chars"
);
assert_eq!(hits[0].char_start, 0);
assert_eq!(matched(text, hits[0]), "S");
let hits = find_all(text, "ss", &opts(false, false));
assert_eq!(hits.len(), 1);
assert_eq!(matched(text, hits[0]), "ß");
let hits = find_all(text, "strasse", &opts(false, false));
assert_eq!(hits.len(), 1);
assert_eq!(
matched(text, hits[0]),
"Straße",
"the whole word, in the source"
);
}
#[test]
fn a_match_covers_the_combining_marks_the_fold_dropped() {
let decomposed = "cafe\u{0301} noir"; let hits = find_all(decomposed, "cafe", &opts(false, false));
assert_eq!(hits.len(), 1);
assert_eq!(
hits[0].char_len, 5,
"four letters plus the combining acute that folded away — a 4-char span would \
leave the accent behind, floating on whatever replaced the word"
);
assert_eq!(matched(decomposed, hits[0]), "cafe\u{0301}");
}
#[test]
fn a_plain_query_finds_accented_prose() {
let text = "la promesse d'Aurélien, et le café";
let hits = find_all(text, "aurelien", &opts(false, false));
assert_eq!(hits.len(), 1);
assert_eq!(matched(text, hits[0]), "Aurélien");
let hits = find_all(text, "cafe", &opts(false, false));
assert_eq!(hits.len(), 1);
assert_eq!(matched(text, hits[0]), "café");
}
#[test]
fn diacritic_sensitive_refuses_the_plain_query() {
let strict = MatchOptions {
diacritic_sensitive: true,
..MatchOptions::default()
};
assert!(find_all("le café", "cafe", &strict).is_empty());
assert_eq!(find_all("le café", "café", &strict).len(), 1);
assert_eq!(
find_all("le CAFÉ", "café", &strict).len(),
1,
"case still folds"
);
}
#[test]
fn whole_word_finds_an_english_possessive() {
let text = "Elena's coat was Elena's alone.";
let hits = find_all(text, "Elena", &opts(false, true));
assert_eq!(hits.len(), 2, "both possessives must be found");
for h in &hits {
assert_eq!(matched(text, *h), "Elena");
}
}
#[test]
fn whole_word_finds_a_word_after_an_elision() {
for text in [
"la promesse d'Aurélien",
"la promesse d\u{2019}Aurélien", ] {
let hits = find_all(text, "Aurélien", &opts(false, true));
assert_eq!(hits.len(), 1, "elided {text:?} must still match");
assert_eq!(matched(text, hits[0]), "Aurélien");
}
}
#[test]
fn the_apostrophe_rule_admits_a_known_false_positive() {
let hits = find_all("I don't know", "don", &opts(false, true));
assert_eq!(
hits.len(),
1,
"documented trade-off: `don` matches the `don` of `don't`"
);
}
#[test]
fn whole_word_sees_through_an_arabic_proclitic() {
for text in ["قرأت الكتاب", "قرأت وَالْكِتَاب", "قرأت ٱلكتاب"]
{
let hits = find_all(text, "كتاب", &opts(false, true));
assert_eq!(
hits.len(),
1,
"the article must not hide the noun in {text:?}"
);
}
assert_eq!(find_all("قرأت كتاب", "كتاب", &opts(false, true)).len(), 1);
}
#[test]
fn the_proclitic_rule_does_not_admit_a_bare_substring() {
assert!(
find_all("الكتابية", "كتاب", &opts(false, true)).is_empty(),
"the occurrence must still END on a word boundary"
);
assert_eq!(find_all("الكتابية", "كتاب", &opts(false, false)).len(), 1);
}
#[test]
fn whole_word_still_rejects_a_substring() {
assert!(
find_all("le marbre", "arbre", &opts(false, true)).is_empty(),
"`arbre` must not match inside `marbre`"
);
assert_eq!(find_all("un arbre", "arbre", &opts(false, true)).len(), 1);
}
#[test]
fn a_catalan_middle_dot_does_not_split_a_word() {
assert!(
find_all("una col·lecció", "lecció", &opts(false, true)).is_empty(),
"the middle dot must not become a word boundary"
);
assert_eq!(
find_all("una col·lecció", "col·leccio", &opts(false, true)).len(),
1
);
}
#[test]
fn a_turkish_scene_keeps_the_two_letters_apart() {
let turkish = MatchOptions {
locale: FoldLocale::Turkic,
..MatchOptions::default()
};
assert_eq!(find_all("KISA bir yol", "kısa", &turkish).len(), 1);
assert!(
find_all("KISA bir yol", "kisa", &turkish).is_empty(),
"in a Turkish scene the dotted i is a different letter"
);
assert_eq!(
find_all("KISA bir yol", "kisa", &MatchOptions::default()).len(),
1
);
}
#[test]
fn case_sensitivity_is_honoured() {
assert!(find_all("Ipsum", "ipsum", &opts(true, false)).is_empty());
assert_eq!(find_all("Ipsum", "Ipsum", &opts(true, false)).len(), 1);
assert_eq!(find_all("Ipsum", "ipsum", &opts(false, false)).len(), 1);
}
#[test]
fn overlapping_and_repeated_occurrences() {
let text = "aaaa";
let hits = find_all(text, "aa", &opts(false, false));
assert_eq!(hits.len(), 2);
assert_eq!(hits[0].char_start, 0);
assert_eq!(hits[1].char_start, 2);
}
#[test]
fn an_empty_query_or_haystack_matches_nothing() {
assert!(find_all("anything", "", &opts(false, false)).is_empty());
assert!(find_all("", "anything", &opts(false, false)).is_empty());
assert!(find_all("café", "\u{0301}", &opts(false, false)).is_empty());
}
#[test]
fn offsets_are_chars_and_survive_multibyte_text() {
let text = "café au lait, CAFÉ noir";
let hits = find_all(text, "café", &opts(false, false));
assert_eq!(hits.len(), 2);
assert_eq!(matched(text, hits[0]), "café");
assert_eq!(
matched(text, hits[1]),
"CAFÉ",
"the second is the uppercase one"
);
}
#[test]
fn preserve_case_recognises_the_three_shapes() {
assert_eq!(
preserve_case("Aurélien", "aurélian", FoldLocale::Root),
"Aurélian"
);
assert_eq!(
preserve_case("AURÉLIEN", "aurélian", FoldLocale::Root),
"AURÉLIAN"
);
assert_eq!(
preserve_case("aurélien", "aurélian", FoldLocale::Root),
"aurélian"
);
assert_eq!(preserve_case("123", "abc", FoldLocale::Root), "abc");
}
#[test]
fn a_single_capital_is_titlecased_not_shouted() {
assert_eq!(preserve_case("O", "elena", FoldLocale::Root), "Elena");
assert_eq!(preserve_case("É", "elena", FoldLocale::Root), "Elena");
assert_eq!(preserve_case("o", "elena", FoldLocale::Root), "elena");
assert_eq!(preserve_case("OK", "elena", FoldLocale::Root), "ELENA");
assert_eq!(preserve_case("O", "ilk", FoldLocale::Turkic), "İlk");
}
#[test]
fn an_unrecognised_shape_leaves_the_replacement_alone() {
assert_eq!(
preserve_case("McDonald", "elena", FoldLocale::Root),
"elena"
);
assert_eq!(preserve_case("iPhone", "elena", FoldLocale::Root), "elena");
assert_eq!(preserve_case("eBay", "elena", FoldLocale::Root), "elena");
}
#[test]
fn heap_size_is_only_stable_once_the_boundaries_are_prepared() {
let text = "un arbre, le marbre, et la forêt d'Aurélien qui s'étirait".repeat(50);
let lazy = FoldedText::new(&text, &FoldSpec::default());
let before = lazy.heap_size();
lazy.find_all("arbre", true); assert!(
lazy.heap_size() > before,
"the boundary table must actually weigh something, or this guards nothing"
);
let prepared = FoldedText::new(&text, &FoldSpec::default());
prepared.prepare_word_boundaries();
let measured = prepared.heap_size();
prepared.find_all("arbre", true);
assert_eq!(
prepared.heap_size(),
measured,
"once prepared, the size a cache recorded must stay the size it holds"
);
}
#[test]
fn preserve_case_uppercases_turkish_correctly() {
assert_eq!(preserve_case("KISA", "ilk", FoldLocale::Turkic), "İLK");
assert_eq!(preserve_case("KISA", "ilk", FoldLocale::Root), "ILK");
assert_eq!(preserve_case("Kısa", "ilk", FoldLocale::Turkic), "İlk");
}
}