Skip to main content

text_document_search/
matching.rs

1//! The one definition of "a match".
2//!
3//! Everything that asks "does this text contain that query" goes through here: the
4//! in-document find, find-and-replace, and (through the public API) a host app's own
5//! project-wide search. Two implementations would drift, and the way a writer meets that
6//! drift is "the editor found it but the search panel didn't".
7//!
8//! ## The rule this module exists to enforce
9//!
10//! **An offset computed in folded text is not valid in the original text.** Folding can
11//! change a string's *length* — `'İ'.to_lowercase()` is two chars, `ß` folds to `ss`, `é`
12//! folds to `e` — so a position found in a folded haystack lands somewhere else entirely
13//! when applied to the source.
14//!
15//! That was not hypothetical. The literal search path used to lowercase the haystack,
16//! compute char offsets *in the lowercased copy*, and hand them back to a caller that
17//! applied them to the original. On `"İİİ ipsum dolor"`, `find_all("ipsum")` reported
18//! position 7 instead of 4, and `replace_text` — running with the default, case-insensitive
19//! options — turned the prose into `"İİİ ipsLOREMlor"`. It did not merely highlight the
20//! wrong word; it *destroyed* the writer's text, and it would do so in any manuscript
21//! containing a Turkish capital İ.
22//!
23//! So folding here always produces an **index map** alongside the folded string, and matches
24//! are always reported in ORIGINAL char offsets.
25//!
26//! ## And a match must not begin or end inside a letter
27//!
28//! Because one source char can fold to several (`Folded`), a substring of the folded text
29//! is not necessarily a substring of anything the writer typed. `Straße` folds to
30//! `strasse`, so a naive scan finds `s` in the middle of the `ß`. There is no source range
31//! to report for half a letter, and replacing it would produce mojibake. Every match is
32//! therefore checked against the index map and **rejected unless it begins and ends on a
33//! source-char boundary** — see `Folded::to_source_match`.
34
35use std::cmp::Ordering;
36use std::sync::OnceLock;
37
38use unicode_segmentation::UnicodeSegmentation;
39
40use crate::folding::{self};
41
42pub use crate::folding::{FoldLocale, FoldSpec};
43
44/// How to match.
45#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)]
46pub struct MatchOptions {
47    /// `false` (the default) folds case — which is why the index map is not optional.
48    pub case_sensitive: bool,
49    /// `false` (the default) folds diacritics, ligatures and Arabic orthography, so that
50    /// `aurelien` finds `Aurélien` and `احمد` finds `أَحْمَد`.
51    pub diacritic_sensitive: bool,
52    /// Only match when the occurrence begins and ends on a word boundary.
53    pub whole_word: bool,
54    /// The language of the text being searched — **per scene**, not per search. One pass
55    /// can fold a French chapter and a Turkish one under different rules; the user's
56    /// toggles above stay global, and the language only decides what folding *means*.
57    pub locale: FoldLocale,
58}
59
60impl MatchOptions {
61    /// The subset of these options that decides how text **folds**.
62    ///
63    /// `whole_word` is deliberately *not* part of it: it decides which matches survive, not
64    /// how a single character folds. Keeping that distinction in the types is what lets
65    /// [`FoldedText`] answer both kinds of query from one fold — so a host app caching the
66    /// fold of a whole manuscript does not throw it away when the writer ticks a checkbox.
67    pub fn fold_spec(&self) -> FoldSpec {
68        FoldSpec {
69            case_sensitive: self.case_sensitive,
70            diacritic_sensitive: self.diacritic_sensitive,
71            locale: self.locale,
72        }
73    }
74}
75
76/// One occurrence, in **char offsets into the original haystack** — never into the folded
77/// copy.
78///
79/// The span covers every source char the match consumed, *including ones the fold elided*: a
80/// query `cafe` matching a decomposed `café` reports 5 chars, not 4, so that replacing it
81/// cannot leave an orphaned combining accent floating on the replacement.
82#[derive(Debug, Clone, Copy, PartialEq, Eq)]
83pub struct Match {
84    pub char_start: usize,
85    pub char_len: usize,
86}
87
88/// Every occurrence of `needle` in `haystack`, in original char offsets.
89///
90/// Folds the haystack, searches it, and throws the fold away. For a **single** search that is
91/// exactly right. For a search box — where the same corpus is searched again on every
92/// keystroke — see [`FoldedText`], which keeps the fold.
93pub fn find_all(haystack: &str, needle: &str, options: &MatchOptions) -> Vec<Match> {
94    FoldedText::new(haystack, &options.fold_spec()).find_all(needle, options.whole_word)
95}
96
97/// A haystack folded **once**, ready to be searched many times.
98///
99/// ## Why this exists
100///
101/// A project-wide search box re-searches the *same* prose on every keystroke, and folding it
102/// is not free. Measured over a 300k-word manuscript: the fold costs about the same as
103/// *parsing* the prose in the first place, and roughly twice what scanning it does — and it
104/// was being rebuilt from scratch for every character the writer typed. A host app holds one
105/// of these per scene and pays that cost once.
106///
107/// ## What it is keyed by, and what it is not
108///
109/// It is built from a [`FoldSpec`] — case, diacritics, language — because those are what
110/// decide how a character folds. **`whole_word` is not one of them**: it decides which matches
111/// survive, not how the text folds. So it is a parameter of [`find_all`](Self::find_all), not
112/// of the fold, and one `FoldedText` answers both kinds of query. That is not a nicety: it
113/// means ticking "whole word" does not throw away a manuscript's worth of folding.
114///
115/// The word-boundary table is built **lazily**, on the first whole-word query, because it is a
116/// second full pass over the text and most searches never ask for it.
117pub struct FoldedText {
118    /// The text as the writer typed it. Retained because [`Match`] offsets address it, and a
119    /// caller that has cached this has usually thrown its own copy away — the alternative is
120    /// every caller keeping a parallel copy and hoping the two stay in step.
121    source: String,
122    folded: Folded,
123    spec: FoldSpec,
124    boundaries: OnceLock<Vec<u32>>,
125}
126
127impl FoldedText {
128    /// Fold `haystack` under `spec`. (`MatchOptions::fold_spec` gets you one.)
129    pub fn new(haystack: &str, spec: &FoldSpec) -> Self {
130        Self {
131            source: haystack.to_string(),
132            folded: Folded::new(haystack, spec),
133            spec: *spec,
134            boundaries: OnceLock::new(),
135        }
136    }
137
138    /// The original text — what the offsets below address, and what a snippet is cut from.
139    pub fn source(&self) -> &str {
140        &self.source
141    }
142
143    /// Every occurrence of `needle`, in **original** char offsets.
144    ///
145    /// `whole_word` is decided here rather than at fold time; see the type's docs.
146    pub fn find_all(&self, needle: &str, whole_word: bool) -> Vec<Match> {
147        if needle.is_empty() || self.folded.text.is_empty() {
148            return Vec::new();
149        }
150        // The needle is folded by the same function the haystack was, so the two can never be
151        // folded by different rules.
152        let folded_needle = fold_query(needle, &self.spec);
153        if folded_needle.is_empty() {
154            return Vec::new();
155        }
156        if !whole_word {
157            return self.folded.matches(&folded_needle);
158        }
159
160        // Boundaries are computed on the ORIGINAL text, so they compose directly with the
161        // original offsets the matcher reports — no second index map needed.
162        let boundaries = self
163            .boundaries
164            .get_or_init(|| word_boundaries(&self.source));
165        let arabic = starts_with_arabic(&folded_needle);
166
167        self.folded
168            .scan(&folded_needle)
169            .filter(|(folded_start, _, m)| {
170                let ends_on_a_word = is_boundary(boundaries, m.char_start + m.char_len);
171                let starts_on_a_word = is_boundary(boundaries, m.char_start)
172                    || (arabic && after_arabic_proclitic(&self.folded, boundaries, *folded_start));
173                starts_on_a_word && ends_on_a_word
174            })
175            .map(|(_, _, m)| m)
176            .collect()
177    }
178
179    /// Build the word-boundary table now, rather than on the first whole-word query.
180    ///
181    /// **A caller that caches a `FoldedText` should call this**, and the reason is
182    /// [`heap_size`](Self::heap_size), not speed. The table is lazy so that the throwaway
183    /// path — fold, search once, drop — does not pay a UAX#29 pass it never uses. But a
184    /// *cached* instance is measured once, when it is stored, and then grows silently the
185    /// first time someone ticks "whole word": its recorded size is stale from that moment on,
186    /// and a cache bounded by the sum of those sizes holds materially more than it believes.
187    ///
188    /// Forcing it here makes the size final, so what the cache measures is what it has.
189    pub fn prepare_word_boundaries(&self) {
190        self.boundaries
191            .get_or_init(|| word_boundaries(&self.source));
192    }
193
194    /// Roughly how many bytes of heap this holds.
195    ///
196    /// A host app caching one per scene has to bound the total, and the honest number is not
197    /// obvious: the index maps are four bytes per *folded char*, so together they outweigh the
198    /// text itself several times over.
199    ///
200    /// **Not stable until [`prepare_word_boundaries`](Self::prepare_word_boundaries) has been
201    /// called** — the boundary table is built lazily, so before then this under-reports by
202    /// whatever that table will come to weigh.
203    pub fn heap_size(&self) -> usize {
204        self.source.capacity()
205            + self.folded.text.capacity()
206            + self.folded.origin.capacity() * 4
207            + self.folded.byte_of_char.capacity() * 4
208            + self.boundaries.get().map_or(0, |b| b.capacity() * 4)
209    }
210}
211
212/// Fold a query. Same function as the haystack's fold, so the two can never be folded by
213/// different rules — but a query has no index map, because nothing maps back into it.
214fn fold_query(needle: &str, spec: &FoldSpec) -> String {
215    let mut out = String::with_capacity(needle.len());
216    let mut chars = needle.chars().peekable();
217    while let Some(c) = chars.next() {
218        let consumed = folding::fold_char(c, chars.peek().copied(), spec, &mut |g| out.push(g));
219        if consumed == 2 {
220            chars.next();
221        }
222    }
223    out
224}
225
226/// A folded haystack, plus the map back to where each folded char came from.
227///
228/// Shared with the regex path (`search_helpers`), which folds diacritics the same way and
229/// needs the same map back — and the same guard against a match that starts inside a letter.
230pub(crate) struct Folded {
231    text: String,
232    /// `origin[i]` is the ORIGINAL char index that produced folded char `i`. One extra
233    /// trailing entry (the original char count) so an *end* offset maps too.
234    ///
235    /// The map is **not** a bijection, in either direction, and both asymmetries matter:
236    /// one source char can produce several folded chars (`ß` → `ss`), and one source char
237    /// can produce **none** (a dropped combining mark, an elided tatweel).
238    origin: Vec<u32>,
239    /// Byte offset of each folded char, so a byte match can be turned into a folded char
240    /// index without building a byte-indexed map of the whole haystack.
241    byte_of_char: Vec<u32>,
242}
243
244impl Folded {
245    /// Fold a haystack under the same rules a query is folded by — the options are the
246    /// *match* options, so the two can never diverge.
247    pub(crate) fn new_for(text: &str, options: &MatchOptions) -> Self {
248        Self::new(text, &options.fold_spec())
249    }
250
251    /// The folded text a scan runs against.
252    pub(crate) fn text(&self) -> &str {
253        &self.text
254    }
255
256    fn new(text: &str, spec: &FoldSpec) -> Self {
257        let mut folded = String::with_capacity(text.len());
258        let mut origin: Vec<u32> = Vec::with_capacity(text.len());
259        let mut byte_of_char: Vec<u32> = Vec::with_capacity(text.len());
260
261        let mut chars = text.chars().enumerate().peekable();
262        let mut source_len = 0usize;
263        while let Some((i, c)) = chars.next() {
264            let next = chars.peek().map(|&(_, c)| c);
265            let consumed = folding::fold_char(c, next, spec, &mut |g| {
266                byte_of_char.push(folded.len() as u32);
267                origin.push(i as u32);
268                folded.push(g);
269            });
270            if consumed == 2 {
271                chars.next();
272            }
273            source_len = i + consumed;
274        }
275        // Sentinels, so a match ending at the very end of the text still maps.
276        byte_of_char.push(folded.len() as u32);
277        origin.push(source_len as u32);
278
279        Self {
280            text: folded,
281            origin,
282            byte_of_char,
283        }
284    }
285
286    /// Folded **char** index of a folded **byte** offset.
287    ///
288    /// Binary search over the per-char byte offsets, rather than a byte-indexed map
289    /// allocated for the whole haystack (8 bytes per *byte* of text — megabytes on a novel,
290    /// rebuilt on every keystroke).
291    pub(crate) fn char_of_byte(&self, byte: usize) -> Option<usize> {
292        self.byte_of_char.binary_search(&(byte as u32)).ok()
293    }
294
295    /// Map a span of the folded text back to a span of the source — or `None` if it does
296    /// not line up with source chars.
297    ///
298    /// **This is the guard.** A folded span that starts or ends *strictly inside* the
299    /// expansion of one source char names no source range at all: the `s` a scan finds in
300    /// the middle of a folded `ß` is half a letter. Reporting it would highlight a
301    /// character that is not there, and *replacing* it would splice into the middle of one.
302    pub(crate) fn to_source_match(&self, folded_start: usize, folded_end: usize) -> Option<Match> {
303        let from = *self.origin.get(folded_start)?;
304        let to = *self.origin.get(folded_end)?;
305
306        // Begins inside a source char's expansion: the preceding folded char came from the
307        // same source char.
308        if folded_start > 0 && self.origin[folded_start - 1] == from {
309            return None;
310        }
311        // Ends inside one: the folded char just past the match came from the same source
312        // char as the last one inside it. The trailing sentinel makes this safe at the very
313        // end of the text.
314        if folded_end > 0 && self.origin[folded_end - 1] == to {
315            return None;
316        }
317
318        Some(Match {
319            char_start: from as usize,
320            char_len: to.saturating_sub(from) as usize,
321        })
322    }
323
324    fn matches(&self, folded_needle: &str) -> Vec<Match> {
325        self.scan(folded_needle).map(|(_, _, m)| m).collect()
326    }
327
328    /// `match_indices` is the two-way algorithm — the literal scan it replaced was an
329    /// O(n·m) char-window byte-slice compare, re-sliced on every character.
330    fn scan<'a>(
331        &'a self,
332        folded_needle: &'a str,
333    ) -> impl Iterator<Item = (usize, usize, Match)> + 'a {
334        let needle_chars = folded_needle.chars().count();
335        self.text
336            .match_indices(folded_needle)
337            .filter_map(move |(byte, _)| {
338                let folded_start = self.char_of_byte(byte)?;
339                let folded_end = folded_start + needle_chars;
340                let m = self.to_source_match(folded_start, folded_end)?;
341                Some((folded_start, folded_end, m))
342            })
343    }
344}
345
346/// Apostrophes that sit *inside* a word under UAX#29 but that a writer means as a word
347/// edge: ASCII `'`, the typographic `’`, and the modifier letter `ʼ`.
348fn is_apostrophe(ch: char) -> bool {
349    matches!(ch, '\u{0027}' | '\u{2019}' | '\u{02BC}')
350}
351
352/// The Arabic script blocks: the main block, the supplement, and Arabic Extended-A. Used
353/// only to decide whether the proclitic rule below is worth consulting at all.
354fn is_arabic(ch: char) -> bool {
355    matches!(ch, '\u{0600}'..='\u{06FF}' | '\u{0750}'..='\u{077F}' | '\u{08A0}'..='\u{08FF}')
356}
357
358fn starts_with_arabic(folded_needle: &str) -> bool {
359    folded_needle.chars().next().is_some_and(is_arabic)
360}
361
362/// Arabic proclitics that a whole-word search must see through.
363///
364/// Arabic fuses its definite article to the noun with **no separator** — `الكتاب` is
365/// "the book" written as one orthographic word — so no boundary rule, in any spec, can find
366/// `كتاب` inside it. That needs a table, and this is it: the article `ال`, and the article
367/// with the conjunctions and prepositions that attach in front of it.
368///
369/// Deliberately **not** the bare single-letter proclitics (`ب ل ك و ف`), nor the Hebrew
370/// prefixes: those letters are frequently a word's own first root letter, so admitting them
371/// would make whole-word search match inside unrelated words far more often than it would
372/// help. Written in the *folded* form, since that is what they are compared against — the
373/// fold has already turned `ٱل` into `ال` and removed any harakat between the letters.
374///
375/// **Scope, stated out loud:** morphological analysis (Arabic or Hebrew stemming, German
376/// decompounding) is out of scope for a *literal* search. Replace on a stemmed match is
377/// undefined — you would rewrite a different surface string from the one on screen.
378const ARABIC_PROCLITICS: &[&str] = &["ال", "وال", "بال", "فال", "كال", "لل"];
379
380/// Is this occurrence preceded, within the same orthographic word, by one of the proclitics
381/// above — with the proclitic itself sitting at a real word boundary?
382fn after_arabic_proclitic(folded: &Folded, boundaries: &[u32], folded_start: usize) -> bool {
383    ARABIC_PROCLITICS.iter().any(|proclitic| {
384        let len = proclitic.chars().count();
385        let Some(prefix_start) = folded_start.checked_sub(len) else {
386            return false;
387        };
388        let from = folded.byte_of_char[prefix_start] as usize;
389        let to = folded.byte_of_char[folded_start] as usize;
390        if &folded.text[from..to] != *proclitic {
391            return false;
392        }
393        // The article must itself start a word: `كتاب` inside `الكتاب` is the article plus
394        // the noun; the same letters in the middle of some other word are not.
395        is_boundary(boundaries, folded.origin[prefix_start] as usize)
396    })
397}
398
399/// Char indices that are word boundaries, sorted and deduplicated.
400///
401/// # The apostrophe
402///
403/// UAX#29 glues an apostrophe *into* a word: `Elena's` is one word, `d'Aurélien` is one
404/// word. So a whole-word search for `Elena` silently missed `Elena's`, and `Aurélien`
405/// missed `d'Aurélien`. In a *search* that is an annoyance; in **Replace All** it is a
406/// half-renamed manuscript — and a missing match is invisible, where a spurious one is
407/// merely skippable.
408///
409/// One rule fixes English possessives and Romance/Celtic/Dutch/Greek elision at once:
410/// **an apostrophe is a boundary on both sides.** The accepted cost is that whole-word
411/// `don` now also matches inside `don't`. That is a visible false positive, and a visible
412/// false positive beats an invisible false negative that corrupts prose.
413///
414/// Deliberately *not* generalised to "any non-letter is a boundary": the hyphen already
415/// breaks under UAX#29 (`Jean-Luc` needs nothing), and Catalan `col·lecció` correctly stays
416/// one word because U+00B7 is `MidLetter` — a blanket rule would *introduce* a Catalan bug
417/// to fix an English one.
418pub(crate) fn word_boundaries(text: &str) -> Vec<u32> {
419    let mut out: Vec<u32> = Vec::new();
420    out.push(0);
421
422    // One pass. The previous version called `text[..byte_start].chars().count()` for every
423    // word — quadratic in the length of the text, on a document that can be a whole novel.
424    let mut words = text.unicode_word_indices().peekable();
425    let mut char_idx: u32 = 0;
426
427    for (byte_idx, ch) in text.char_indices() {
428        while let Some(&(word_byte, word)) = words.peek() {
429            match word_byte.cmp(&byte_idx) {
430                Ordering::Less => {
431                    words.next();
432                }
433                Ordering::Equal => {
434                    out.push(char_idx);
435                    out.push(char_idx + word.chars().count() as u32);
436                    words.next();
437                }
438                Ordering::Greater => break,
439            }
440        }
441        if is_apostrophe(ch) {
442            out.push(char_idx);
443            out.push(char_idx + 1);
444        }
445        char_idx += 1;
446    }
447    out.push(char_idx);
448
449    out.sort_unstable();
450    out.dedup();
451    out
452}
453
454/// A sorted `Vec<u32>` + binary search, not a `HashSet<usize>`: half the memory, and
455/// contiguous rather than pointer-chasing on every candidate match.
456pub(crate) fn is_boundary(boundaries: &[u32], char_idx: usize) -> bool {
457    boundaries.binary_search(&(char_idx as u32)).is_ok()
458}
459
460/// Dress `replacement` in the case of the text it is replacing.
461///
462/// A reviewed rename hits `Aurélien`, `AURÉLIEN` and `aurélien` in the same manuscript, and
463/// the writer means one rename, not three.
464///
465/// Exactly three shapes are recognised, and everything else is left **verbatim**. That last
466/// part is the important one: this function guesses at what a writer meant, and a guess it is
467/// not sure about must not be written into their book. `McDonald`, `iPhone`, `eBay` are
468/// deliberately not "initial capital" — they are their own thing, and the replacement the
469/// caller supplied is a better answer than anything inferred from them.
470///
471/// ## ALL CAPS needs **two** letters
472///
473/// A single capital is not shouting. `O`, `K`, `É` are all-uppercase by inspection and
474/// titlecase by intent, and treating them as SHOUTING turns a rename of a one-letter name
475/// into `ELENA` where the prose wanted `Elena` — silently, at every occurrence, in someone's
476/// manuscript. There is no way to tell the two apart from one letter, so the tie goes to the
477/// quieter answer.
478///
479/// ## Locale
480///
481/// In Turkish the uppercase of `i` is `İ`, not `I`. A case-preserver blind to that would
482/// rewrite Turkish prose into a different word — the same class of silent corruption as
483/// folding the dotless `ı` onto `i`.
484pub fn preserve_case(matched: &str, replacement: &str, locale: FoldLocale) -> String {
485    let letters: Vec<char> = matched.chars().filter(|c| c.is_alphabetic()).collect();
486    let Some(&first) = letters.first() else {
487        // Nothing cased to copy from (digits, punctuation): take the replacement as given.
488        return replacement.to_string();
489    };
490
491    // ALL CAPS — and it takes at least two letters to say so. See above.
492    if letters.len() > 1 && letters.iter().all(|c| c.is_uppercase()) {
493        let mut out = String::with_capacity(replacement.len());
494        for c in replacement.chars() {
495            folding::to_upper(c, locale, &mut out);
496        }
497        return out;
498    }
499
500    // Titlecase — a leading capital and *nothing else* capitalised. `McDonald` fails this on
501    // purpose: it is neither titlecase nor shouting, so the replacement stands as written.
502    if first.is_uppercase() && letters[1..].iter().all(|c| c.is_lowercase()) {
503        let mut out = String::with_capacity(replacement.len());
504        let mut chars = replacement.chars();
505        if let Some(c) = chars.next() {
506            folding::to_upper(c, locale, &mut out);
507        }
508        out.extend(chars);
509        return out;
510    }
511
512    replacement.to_string()
513}
514
515#[cfg(test)]
516mod tests {
517    use super::*;
518
519    fn opts(case_sensitive: bool, whole_word: bool) -> MatchOptions {
520        MatchOptions {
521            case_sensitive,
522            whole_word,
523            ..MatchOptions::default()
524        }
525    }
526
527    /// The matched text, sliced out of the ORIGINAL by the reported offsets. If the offsets
528    /// are wrong, this is wrong — which is the entire failure mode.
529    fn matched(haystack: &str, m: Match) -> String {
530        haystack
531            .chars()
532            .skip(m.char_start)
533            .take(m.char_len)
534            .collect()
535    }
536
537    /// The bug this module exists for.
538    ///
539    /// `İ` lowercases to TWO chars, so offsets found in the lowercased haystack drift.
540    /// `find_all` used to report 7 instead of 4 here, and the replace built on it turned
541    /// `"İİİ ipsum dolor"` into `"İİİ ipsLOREMlor"` — silent corruption of a writer's prose,
542    /// under the DEFAULT (case-insensitive) options.
543    #[test]
544    fn an_offset_from_folded_text_is_reported_in_the_source() {
545        let text = "İİİ ipsum dolor";
546        assert!(
547            text.to_lowercase().chars().count() > text.chars().count(),
548            "this fixture must actually grow when lowercased, or it proves nothing"
549        );
550
551        let hits = find_all(text, "ipsum", &opts(false, false));
552        assert_eq!(hits.len(), 1);
553        assert_eq!(
554            hits[0].char_start, 4,
555            "the offset must be valid in the SOURCE"
556        );
557        assert_eq!(matched(text, hits[0]), "ipsum");
558    }
559
560    /// The guard. `Straße` folds to `strasse`, so a scan finds `s` in the middle of the `ß`
561    /// — a position that names half a letter. There is no source range to report for it, and
562    /// splicing there would produce mojibake.
563    #[test]
564    fn a_query_cannot_match_half_of_a_folded_letter() {
565        let text = "Straße";
566        let hits = find_all(text, "s", &opts(false, false));
567        assert_eq!(
568            hits.len(),
569            1,
570            "only the leading S — never half of the ß, which folds to two chars"
571        );
572        assert_eq!(hits[0].char_start, 0);
573        assert_eq!(matched(text, hits[0]), "S");
574
575        // …but a query that covers the WHOLE letter is a real match, and reports the letter.
576        let hits = find_all(text, "ss", &opts(false, false));
577        assert_eq!(hits.len(), 1);
578        assert_eq!(matched(text, hits[0]), "ß");
579
580        let hits = find_all(text, "strasse", &opts(false, false));
581        assert_eq!(hits.len(), 1);
582        assert_eq!(
583            matched(text, hits[0]),
584            "Straße",
585            "the whole word, in the source"
586        );
587    }
588
589    /// A match must swallow the source chars the fold elided, or a replace leaves an
590    /// orphaned combining accent stranded on the replacement text.
591    #[test]
592    fn a_match_covers_the_combining_marks_the_fold_dropped() {
593        let decomposed = "cafe\u{0301} noir"; // NFD: the é is two chars
594        let hits = find_all(decomposed, "cafe", &opts(false, false));
595        assert_eq!(hits.len(), 1);
596        assert_eq!(
597            hits[0].char_len, 5,
598            "four letters plus the combining acute that folded away — a 4-char span would \
599             leave the accent behind, floating on whatever replaced the word"
600        );
601        // The source is NFD, so the matched text is too: `c a f e ◌́`, which *is* "café".
602        assert_eq!(matched(decomposed, hits[0]), "cafe\u{0301}");
603    }
604
605    /// The point of the whole fold: plain ASCII finds accented prose, and the reported span
606    /// is the accented text.
607    #[test]
608    fn a_plain_query_finds_accented_prose() {
609        let text = "la promesse d'Aurélien, et le café";
610        let hits = find_all(text, "aurelien", &opts(false, false));
611        assert_eq!(hits.len(), 1);
612        assert_eq!(matched(text, hits[0]), "Aurélien");
613
614        let hits = find_all(text, "cafe", &opts(false, false));
615        assert_eq!(hits.len(), 1);
616        assert_eq!(matched(text, hits[0]), "café");
617    }
618
619    /// …and the toggle turns it off.
620    #[test]
621    fn diacritic_sensitive_refuses_the_plain_query() {
622        let strict = MatchOptions {
623            diacritic_sensitive: true,
624            ..MatchOptions::default()
625        };
626        assert!(find_all("le café", "cafe", &strict).is_empty());
627        assert_eq!(find_all("le café", "café", &strict).len(), 1);
628        assert_eq!(
629            find_all("le CAFÉ", "café", &strict).len(),
630            1,
631            "case still folds"
632        );
633    }
634
635    /// Whole-word `Elena` must find `Elena's`. UAX#29 glues the apostrophe into the word, so
636    /// this used to miss — and a missed match in Replace All is a half-renamed manuscript.
637    #[test]
638    fn whole_word_finds_an_english_possessive() {
639        let text = "Elena's coat was Elena's alone.";
640        let hits = find_all(text, "Elena", &opts(false, true));
641        assert_eq!(hits.len(), 2, "both possessives must be found");
642        for h in &hits {
643            assert_eq!(matched(text, *h), "Elena");
644        }
645    }
646
647    /// …and the same one rule covers Romance elision: `d'Aurélien` contains `Aurélien`.
648    #[test]
649    fn whole_word_finds_a_word_after_an_elision() {
650        for text in [
651            "la promesse d'Aurélien",
652            "la promesse d\u{2019}Aurélien", // typographic apostrophe
653        ] {
654            let hits = find_all(text, "Aurélien", &opts(false, true));
655            assert_eq!(hits.len(), 1, "elided {text:?} must still match");
656            assert_eq!(matched(text, hits[0]), "Aurélien");
657        }
658    }
659
660    /// The accepted cost of the apostrophe rule, stated out loud: `don` matches inside
661    /// `don't`. A visible false positive the writer can skip beats an invisible false
662    /// negative that silently half-renames their book.
663    #[test]
664    fn the_apostrophe_rule_admits_a_known_false_positive() {
665        let hits = find_all("I don't know", "don", &opts(false, true));
666        assert_eq!(
667            hits.len(),
668            1,
669            "documented trade-off: `don` matches the `don` of `don't`"
670        );
671    }
672
673    /// Arabic fuses the article to the noun with no separator, so **no** boundary rule can
674    /// see the seam. Whole-word `كتاب` must still find `الكتاب`.
675    #[test]
676    fn whole_word_sees_through_an_arabic_proclitic() {
677        for text in ["قرأت الكتاب", "قرأت وَالْكِتَاب", "قرأت ٱلكتاب"]
678        {
679            let hits = find_all(text, "كتاب", &opts(false, true));
680            assert_eq!(
681                hits.len(),
682                1,
683                "the article must not hide the noun in {text:?}"
684            );
685        }
686        // …and with no proclitic at all it is simply a word.
687        assert_eq!(find_all("قرأت كتاب", "كتاب", &opts(false, true)).len(), 1);
688    }
689
690    /// The proclitic rule is a *prefix* rule, not "these letters anywhere". The article must
691    /// itself begin a word, or `كتاب` would match inside words that merely happen to contain
692    /// those letters.
693    #[test]
694    fn the_proclitic_rule_does_not_admit_a_bare_substring() {
695        // `الكتابية` — the noun is followed by more letters, so the END is not a boundary.
696        assert!(
697            find_all("الكتابية", "كتاب", &opts(false, true)).is_empty(),
698            "the occurrence must still END on a word boundary"
699        );
700        // Without whole_word, it is a plain substring match and does occur.
701        assert_eq!(find_all("الكتابية", "كتاب", &opts(false, false)).len(), 1);
702    }
703
704    /// Whole-word must still refuse a real substring.
705    #[test]
706    fn whole_word_still_rejects_a_substring() {
707        assert!(
708            find_all("le marbre", "arbre", &opts(false, true)).is_empty(),
709            "`arbre` must not match inside `marbre`"
710        );
711        assert_eq!(find_all("un arbre", "arbre", &opts(false, true)).len(), 1);
712    }
713
714    /// Catalan `col·lecció` is ONE word (U+00B7 is deliberately MidLetter). A blanket "any
715    /// non-letter is a boundary" rule would have broken this to fix the apostrophe.
716    #[test]
717    fn a_catalan_middle_dot_does_not_split_a_word() {
718        assert!(
719            find_all("una col·lecció", "lecció", &opts(false, true)).is_empty(),
720            "the middle dot must not become a word boundary"
721        );
722        // …and the whole word is found, middle dot and accents folded alike.
723        assert_eq!(
724            find_all("una col·lecció", "col·leccio", &opts(false, true)).len(),
725            1
726        );
727    }
728
729    /// Turkish, end to end: the scene's language changes what the same query finds.
730    #[test]
731    fn a_turkish_scene_keeps_the_two_letters_apart() {
732        let turkish = MatchOptions {
733            locale: FoldLocale::Turkic,
734            ..MatchOptions::default()
735        };
736        assert_eq!(find_all("KISA bir yol", "kısa", &turkish).len(), 1);
737        assert!(
738            find_all("KISA bir yol", "kisa", &turkish).is_empty(),
739            "in a Turkish scene the dotted i is a different letter"
740        );
741        // The same prose in an untailored scene folds them together.
742        assert_eq!(
743            find_all("KISA bir yol", "kisa", &MatchOptions::default()).len(),
744            1
745        );
746    }
747
748    #[test]
749    fn case_sensitivity_is_honoured() {
750        assert!(find_all("Ipsum", "ipsum", &opts(true, false)).is_empty());
751        assert_eq!(find_all("Ipsum", "Ipsum", &opts(true, false)).len(), 1);
752        assert_eq!(find_all("Ipsum", "ipsum", &opts(false, false)).len(), 1);
753    }
754
755    #[test]
756    fn overlapping_and_repeated_occurrences() {
757        let text = "aaaa";
758        let hits = find_all(text, "aa", &opts(false, false));
759        // `match_indices` reports non-overlapping matches, which is what a replace needs.
760        assert_eq!(hits.len(), 2);
761        assert_eq!(hits[0].char_start, 0);
762        assert_eq!(hits[1].char_start, 2);
763    }
764
765    #[test]
766    fn an_empty_query_or_haystack_matches_nothing() {
767        assert!(find_all("anything", "", &opts(false, false)).is_empty());
768        assert!(find_all("", "anything", &opts(false, false)).is_empty());
769        // A query that folds away to nothing (a lone combining mark) matches nothing
770        // either — rather than matching everywhere.
771        assert!(find_all("café", "\u{0301}", &opts(false, false)).is_empty());
772    }
773
774    /// Multi-byte text: offsets are CHARS, and must survive a fold that changes length.
775    #[test]
776    fn offsets_are_chars_and_survive_multibyte_text() {
777        let text = "café au lait, CAFÉ noir";
778        let hits = find_all(text, "café", &opts(false, false));
779        assert_eq!(hits.len(), 2);
780        assert_eq!(matched(text, hits[0]), "café");
781        assert_eq!(
782            matched(text, hits[1]),
783            "CAFÉ",
784            "the second is the uppercase one"
785        );
786    }
787
788    #[test]
789    fn preserve_case_recognises_the_three_shapes() {
790        assert_eq!(
791            preserve_case("Aurélien", "aurélian", FoldLocale::Root),
792            "Aurélian"
793        );
794        assert_eq!(
795            preserve_case("AURÉLIEN", "aurélian", FoldLocale::Root),
796            "AURÉLIAN"
797        );
798        assert_eq!(
799            preserve_case("aurélien", "aurélian", FoldLocale::Root),
800            "aurélian"
801        );
802        // Nothing cased to copy: leave the replacement exactly as given.
803        assert_eq!(preserve_case("123", "abc", FoldLocale::Root), "abc");
804    }
805
806    /// **A single capital is not shouting.** Nothing distinguishes a one-letter titlecase word
807    /// from a one-letter shout, so the tie goes to the quieter answer — otherwise renaming a
808    /// character called `O` to `Elena` writes `ELENA` into the manuscript, at every
809    /// occurrence, and the writer finds out by reading it.
810    #[test]
811    fn a_single_capital_is_titlecased_not_shouted() {
812        assert_eq!(preserve_case("O", "elena", FoldLocale::Root), "Elena");
813        assert_eq!(preserve_case("É", "elena", FoldLocale::Root), "Elena");
814        assert_eq!(preserve_case("o", "elena", FoldLocale::Root), "elena");
815        // …but two capitals ARE shouting.
816        assert_eq!(preserve_case("OK", "elena", FoldLocale::Root), "ELENA");
817        // …and the Turkish tailoring still applies to the single-capital path.
818        assert_eq!(preserve_case("O", "ilk", FoldLocale::Turkic), "İlk");
819    }
820
821    /// Anything that is neither titlecase nor shouting is left **verbatim**. This function
822    /// guesses at what a writer meant, and a guess it is not sure about must not be written
823    /// into their book: the replacement they typed is a better answer than one inferred from
824    /// `McDonald`.
825    #[test]
826    fn an_unrecognised_shape_leaves_the_replacement_alone() {
827        assert_eq!(
828            preserve_case("McDonald", "elena", FoldLocale::Root),
829            "elena"
830        );
831        assert_eq!(preserve_case("iPhone", "elena", FoldLocale::Root), "elena");
832        assert_eq!(preserve_case("eBay", "elena", FoldLocale::Root), "elena");
833    }
834
835    /// `heap_size` grows when the lazy boundary table is built, and `prepare_word_boundaries`
836    /// is what lets a cache measure an entry once and have the number stay true. Without it a
837    /// cache bounded by the sum of these sizes silently holds more than it thinks.
838    #[test]
839    fn heap_size_is_only_stable_once_the_boundaries_are_prepared() {
840        let text = "un arbre, le marbre, et la forêt d'Aurélien qui s'étirait".repeat(50);
841
842        let lazy = FoldedText::new(&text, &FoldSpec::default());
843        let before = lazy.heap_size();
844        lazy.find_all("arbre", true); // the first whole-word query builds the table
845        assert!(
846            lazy.heap_size() > before,
847            "the boundary table must actually weigh something, or this guards nothing"
848        );
849
850        let prepared = FoldedText::new(&text, &FoldSpec::default());
851        prepared.prepare_word_boundaries();
852        let measured = prepared.heap_size();
853        prepared.find_all("arbre", true);
854        assert_eq!(
855            prepared.heap_size(),
856            measured,
857            "once prepared, the size a cache recorded must stay the size it holds"
858        );
859    }
860
861    /// The Turkish trap: the uppercase of `i` is `İ`. An untailored case-preserver would
862    /// write `I`, which is the uppercase of a *different letter*.
863    #[test]
864    fn preserve_case_uppercases_turkish_correctly() {
865        assert_eq!(preserve_case("KISA", "ilk", FoldLocale::Turkic), "İLK");
866        assert_eq!(preserve_case("KISA", "ilk", FoldLocale::Root), "ILK");
867        assert_eq!(preserve_case("Kısa", "ilk", FoldLocale::Turkic), "İlk");
868    }
869}