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}