Skip to main content

text_document_search/
folding.rs

1//! The fold: turning a source char into the chars a *match* compares.
2//!
3//! A writer looking for `Aurelien` expects to find `Aurélien`; one looking for `احمد`
4//! expects `أَحْمَد`; one looking for `strasse` expects `Straße`. None of those are string
5//! equality, and none of them are `to_lowercase`.
6//!
7//! ## The pipeline, per source char
8//!
9//! 1. **Turkic tailoring**, if the text's language is Turkish or Azerbaijani (below).
10//! 2. **Canonical caseless matching**, exactly as Unicode 3.13 defines it: `NFD → case-fold
11//!    → NFD`. Case folding is not lowercasing — `ß` folds to `ss`, `fi` to `fi`, `ς` and `Σ`
12//!    both to `σ`.
13//! 3. **Drop nonspacing marks** (`General_Category = Mn`). One rule covers Latin accents,
14//!    Arabic harakat *and* Hebrew niqqud. Deliberately not the `Diacritic` property (too
15//!    broad) and deliberately not `Mc` — a Devanagari vowel sign is not decoration.
16//! 4. **The letter table**, for what no normalization form can reach.
17//! 5. **Arabic orthographic normalization**, and the tatweel.
18//!
19//! Steps 3–5 are gated by `diacritic_sensitive`; step 2's fold by `case_sensitive`.
20//!
21//! ## Why the marks are dropped rather than the `Diacritic` property tested
22//!
23//! `ø ł đ ħ ı ŧ æ œ þ ð` have an **empty** canonical decomposition — the stroke is part of
24//! the letter, not a combining mark, so *no* normalization form touches them. They need the
25//! table in step 4, and the table is keyed on the already-case-folded char so that `Ø` and
26//! `ø` reach it alike. Get that wrong and `Ørsted` is not found by `orsted`, which is the
27//! kind of miss a writer never reports as a bug — they just conclude the search is broken.
28//!
29//! `ß` is deliberately **absent** from the table: full case folding already owns it. Having
30//! it in both would mean the table always won, and the `Straße`/`strasse` test would prove
31//! nothing about the case fold it was written to verify.
32//!
33//! ## Why Turkic runs first, before NFD
34//!
35//! `İ` (U+0130) canonically decomposes to `I` + COMBINING DOT ABOVE. Fold *that* under the
36//! Turkic rules and the `I` becomes `ı` — the **dotless** letter, which in Turkish is a
37//! different letter from `i` and means a different word. Turkish `İstanbul` would fold to
38//! `ıstanbul` and never be found. The tailoring is defined on the composed letter, so it
39//! must be applied to the composed letter — before anything decomposes it.
40//!
41//! Locale is the *only* axis case folding has (there is no Greek or Lithuanian tailoring of
42//! `Case_Folding`, unlike of lower/upper/title), and `tr`/`az` is the whole of it: two code
43//! points, which is the entire `T` status in `CaseFolding.txt`.
44
45use caseless::Caseless;
46use unicode_normalization::UnicodeNormalization;
47use unicode_properties::{GeneralCategory, UnicodeGeneralCategory};
48
49/// COMBINING DOT ABOVE — the mark an already-decomposed `İ` carries.
50const COMBINING_DOT_ABOVE: char = '\u{0307}';
51
52/// ARABIC TATWEEL: a pure typographic stretch with no phonetic value. Dropped, so that
53/// `كتاب` finds `كــتــاب`.
54const TATWEEL: char = '\u{0640}';
55
56/// The locale tailoring that changes how text folds.
57///
58/// Only Turkish and Azerbaijani change *folding* (the dotted/dotless i), so this is not a
59/// full locale — carrying one would invite the reader to expect tailoring that does not
60/// exist.
61#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)]
62pub enum FoldLocale {
63    /// Untailored — the `und` root locale.
64    #[default]
65    Root,
66    /// Turkish and Azerbaijani: `I` folds to `ı` and `İ` to `i`, and the dotless `ı` is a
67    /// letter in its own right rather than an `i` wearing a missing dot.
68    Turkic,
69}
70
71impl FoldLocale {
72    /// Resolve a BCP-47-ish language tag.
73    ///
74    /// A tag that is empty, unknown or **malformed** falls back to [`FoldLocale::Root`]. It
75    /// never fails: the tag comes from a writer's project settings, and a typo there must
76    /// degrade to an untailored search, not break searching.
77    pub fn from_tag(tag: &str) -> Self {
78        let primary = tag.split(['-', '_']).next().unwrap_or("");
79        if primary.eq_ignore_ascii_case("tr") || primary.eq_ignore_ascii_case("az") {
80            FoldLocale::Turkic
81        } else {
82            FoldLocale::Root
83        }
84    }
85}
86
87/// What a fold is allowed to fold away.
88#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)]
89pub struct FoldSpec {
90    /// `false` (the default) folds case.
91    pub case_sensitive: bool,
92    /// `false` (the default) folds diacritics — steps 3–5 above.
93    pub diacritic_sensitive: bool,
94    /// How to fold, never *whether* to. The toggles above stay global across a search; the
95    /// language only decides what folding *means* in this particular scene — or the same
96    /// checkbox would mean different things in different chapters of one book.
97    pub locale: FoldLocale,
98}
99
100/// Letters whose canonical decomposition is **empty** — the stroke, slash or ligature is
101/// part of the letter, so no normalization form will ever separate it. Sorted by code
102/// point; binary-searched. Both cases are listed, because in a *case-sensitive* search
103/// nothing has folded `Ø` down to `ø` by the time we get here.
104///
105/// `ß` is not here on purpose (see the module docs), and neither are the Arabic letters
106/// that canonical decomposition *does* reach: `أ إ آ` all decompose to `ا` plus a
107/// nonspacing mark, which step 3 removes.
108const LETTER_FOLD: &[(char, &str)] = &[
109    ('\u{00C6}', "AE"),       // Æ
110    ('\u{00D0}', "D"),        // Ð
111    ('\u{00D8}', "O"),        // Ø
112    ('\u{00DE}', "TH"),       // Þ
113    ('\u{00E6}', "ae"),       // æ
114    ('\u{00F0}', "d"),        // ð
115    ('\u{00F8}', "o"),        // ø
116    ('\u{00FE}', "th"),       // þ
117    ('\u{0110}', "D"),        // Đ
118    ('\u{0111}', "d"),        // đ
119    ('\u{0126}', "H"),        // Ħ
120    ('\u{0127}', "h"),        // ħ
121    ('\u{0131}', "i"),        // ı  — suppressed under Turkic
122    ('\u{0141}', "L"),        // Ł
123    ('\u{0142}', "l"),        // ł
124    ('\u{0152}', "OE"),       // Œ
125    ('\u{0153}', "oe"),       // œ
126    ('\u{0166}', "T"),        // Ŧ
127    ('\u{0167}', "t"),        // ŧ
128    ('\u{0629}', "\u{0647}"), // ة teh marbuta -> ه heh
129    ('\u{0649}', "\u{064A}"), // ى alef maksura -> ي yeh
130    ('\u{0671}', "\u{0627}"), // ٱ alef wasla  -> ا alef  (the one alef form NFD cannot reach)
131];
132
133fn letter_fold(c: char, locale: FoldLocale) -> Option<&'static str> {
134    // In Turkish and Azerbaijani the dotless `ı` is a letter, not an `i` that lost its dot.
135    // Folding it to `i` here would reintroduce the very confusion the Turkic case tailoring
136    // exists to prevent — `kısa` ("short") would match `kisa`.
137    if locale == FoldLocale::Turkic && c == '\u{0131}' {
138        return None;
139    }
140    LETTER_FOLD
141        .binary_search_by_key(&c, |&(k, _)| k)
142        .ok()
143        .map(|i| LETTER_FOLD[i].1)
144}
145
146/// The tail of the pipeline: steps 3–5, applied to a char that has already been decomposed
147/// and case-folded.
148fn emit_folded(c: char, spec: &FoldSpec, emit: &mut impl FnMut(char)) {
149    if spec.diacritic_sensitive {
150        emit(c);
151        return;
152    }
153    if c == TATWEEL || c.general_category() == GeneralCategory::NonspacingMark {
154        return;
155    }
156    match letter_fold(c, spec.locale) {
157        Some(replacement) => replacement.chars().for_each(&mut *emit),
158        None => emit(c),
159    }
160}
161
162/// Fold one source char, emitting the 0..n chars it contributes to the folded text.
163///
164/// Returns how many **source** chars were consumed — normally 1, but 2 when an
165/// already-decomposed Turkish `İ` (`I` + COMBINING DOT ABOVE) was recomposed on the fly.
166/// `next` is the following source char, needed only for that case.
167///
168/// A char can legitimately emit **nothing** (a dropped mark, a tatweel). Callers building
169/// an index map must therefore not assume one entry per source char — that asymmetry is the
170/// entire reason [`crate::matching::Folded`] exists.
171pub(crate) fn fold_char(
172    c: char,
173    next: Option<char>,
174    spec: &FoldSpec,
175    emit: &mut impl FnMut(char),
176) -> usize {
177    // ASCII is the overwhelming majority of any prose — even French, even Turkish — and for
178    // ASCII the whole pipeline below collapses to one branch. Every step of it is provably a
179    // no-op here:
180    //
181    //   * no ASCII char has a canonical decomposition, so NFD is the identity;
182    //   * `CaseFolding.txt`'s only ASCII entries are `A..Z -> a..z`;
183    //   * no ASCII char is a nonspacing mark, none is in the letter table (whose lowest key
184    //     is U+00C6), and none is the tatweel.
185    //
186    // The one exception is Turkish `I`, which folds to the **dotless** `ı` — so it is
187    // excluded here and falls through to the tailoring below.
188    //
189    // This is not a micro-optimisation. Measured over a 300k-word manuscript, the general
190    // path costs **172 ms** — an NFD iterator, a binary search over the ~1500-entry
191    // case-folding table and a property lookup, per character of the whole novel, on every
192    // keystroke. It is 15x the cost of the scan it exists to serve, and 5x the cost of
193    // parsing the prose in the first place.
194    //
195    // It is a *pure* speedup, not an approximation: `the_fast_path_agrees_with_the_general
196    // _path_on_every_char` checks the two against each other across the whole of Unicode,
197    // under every combination of the toggles.
198    if c.is_ascii() && !(spec.locale == FoldLocale::Turkic && c == 'I') {
199        emit(if spec.case_sensitive {
200            c
201        } else {
202            c.to_ascii_lowercase()
203        });
204        return 1;
205    }
206
207    fold_char_general(c, next, spec, emit)
208}
209
210/// The full pipeline, with no ASCII shortcut. Kept as its own function so the fast path can
211/// be checked against it over every char in Unicode rather than trusted.
212fn fold_char_general(
213    c: char,
214    next: Option<char>,
215    spec: &FoldSpec,
216    emit: &mut impl FnMut(char),
217) -> usize {
218    if !spec.case_sensitive && spec.locale == FoldLocale::Turkic {
219        match c {
220            '\u{0130}' => {
221                // İ -> i. Composed, so NFD has not had a chance to break it apart.
222                emit_folded('i', spec, emit);
223                return 1;
224            }
225            'I' if next == Some(COMBINING_DOT_ABOVE) => {
226                // The same İ, stored decomposed (a file that came through a system
227                // normalising to NFD). Same letter, so the same answer — and the dot is
228                // consumed with it rather than being left to fold on its own.
229                emit_folded('i', spec, emit);
230                return 2;
231            }
232            'I' => {
233                emit_folded('\u{0131}', spec, emit);
234                return 1;
235            }
236            _ => {}
237        }
238    }
239
240    if spec.diacritic_sensitive {
241        // Literal about marks, so nothing decomposes: `é` stays one char. Decomposing here
242        // would make `is_identity()` a lie — a caller that trusted it and skipped the index
243        // map back to the source would then read offsets into a string one char longer than
244        // the one the writer typed.
245        //
246        // The consequence, stated: a diacritic-sensitive search is literal about the
247        // *encoding* too, so a precomposed `é` and a decomposed `e`+◌́ are different text.
248        // Within one document they never are — it all came through one importer — and
249        // "sensitive" is precisely a request to stop being clever.
250        if spec.case_sensitive {
251            emit(c);
252        } else {
253            for folded in std::iter::once(c).default_case_fold() {
254                emit_folded(folded, spec, emit);
255            }
256        }
257        return 1;
258    }
259
260    // Canonical caseless matching: NFD -> fold -> NFD. The second NFD is not redundant —
261    // folding can *produce* a composed char carrying a mark (`ǰ` -> `j` + ◌̌, `ΐ` -> `ι` +
262    // ◌̈ + ◌́), and marks can only be dropped once they are visible as marks.
263    for decomposed in c.nfd() {
264        if spec.case_sensitive {
265            emit_folded(decomposed, spec, emit);
266        } else {
267            for folded in std::iter::once(decomposed).default_case_fold() {
268                for renormalised in folded.nfd() {
269                    emit_folded(renormalised, spec, emit);
270                }
271            }
272        }
273    }
274    1
275}
276
277/// Uppercase one char under the locale's rules.
278///
279/// Rust's `to_uppercase` is the untailored mapping, so `i` becomes `I`. In Turkish that is
280/// the *wrong letter*: the uppercase of `i` is `İ`, and `I` is the uppercase of the dotless
281/// `ı`. A case-preserving rename that used the untailored mapping would silently rewrite
282/// Turkish prose into a different word.
283pub(crate) fn to_upper(c: char, locale: FoldLocale, out: &mut String) {
284    if locale == FoldLocale::Turkic {
285        match c {
286            'i' => {
287                out.push('\u{0130}');
288                return;
289            }
290            '\u{0131}' => {
291                out.push('I');
292                return;
293            }
294            _ => {}
295        }
296    }
297    out.extend(c.to_uppercase());
298}
299
300#[cfg(test)]
301mod tests {
302    use super::*;
303
304    fn fold(text: &str, spec: &FoldSpec) -> String {
305        let mut out = String::new();
306        let mut it = text.chars().peekable();
307        while let Some(c) = it.next() {
308            let consumed = fold_char(c, it.peek().copied(), spec, &mut |g| out.push(g));
309            if consumed == 2 {
310                it.next();
311            }
312        }
313        out
314    }
315
316    /// The default: fold case and diacritics both.
317    fn loose() -> FoldSpec {
318        FoldSpec::default()
319    }
320
321    fn turkic() -> FoldSpec {
322        FoldSpec {
323            locale: FoldLocale::Turkic,
324            ..FoldSpec::default()
325        }
326    }
327
328    /// `binary_search` on an unsorted table silently returns `Err` — every letter in it
329    /// would stop folding, and nothing would fail loudly.
330    #[test]
331    fn the_letter_table_is_sorted_and_has_no_duplicates() {
332        let keys: Vec<char> = LETTER_FOLD.iter().map(|&(k, _)| k).collect();
333        let mut sorted = keys.clone();
334        sorted.sort_unstable();
335        sorted.dedup();
336        assert_eq!(keys, sorted, "LETTER_FOLD must be sorted and unique");
337    }
338
339    /// The headline: a writer types plain ASCII and finds the accented word.
340    #[test]
341    fn a_plain_query_folds_onto_accented_prose() {
342        for (accented, plain) in [
343            ("Aurélien", "aurelien"),
344            ("café", "cafe"),
345            ("Brønnøysund", "bronnoysund"),
346            ("œuvre", "oeuvre"),
347            ("Ægir", "aegir"),
348            ("Þingvellir", "thingvellir"),
349            ("Łódź", "lodz"),
350            ("ệ", "e"),
351        ] {
352            assert_eq!(
353                fold(accented, &loose()),
354                fold(plain, &loose()),
355                "{accented:?} must be found by {plain:?}"
356            );
357        }
358    }
359
360    /// The capital-letter case that would otherwise have shipped broken. A table keyed on
361    /// the *raw* char and holding only lowercase entries lets `Ø` fall through to case
362    /// folding, become `ø`, and never reach the table — so `orsted` would not find
363    /// `Ørsted`, with both toggles at their defaults.
364    #[test]
365    fn the_letter_table_is_reached_by_the_uppercase_form_too() {
366        assert_eq!(fold("Ørsted", &loose()), fold("orsted", &loose()));
367        assert_eq!(fold("Ørsted", &loose()), "orsted");
368    }
369
370    /// …and in a *case-sensitive*, diacritic-folding search the case must survive the
371    /// table: `Ø` becomes `O`, not `o`.
372    #[test]
373    fn the_letter_table_keeps_case_when_case_matters() {
374        let spec = FoldSpec {
375            case_sensitive: true,
376            ..FoldSpec::default()
377        };
378        assert_eq!(fold("Ørsted", &spec), "Orsted");
379        assert_eq!(fold("ÆØÅ", &spec), "AEOA");
380        assert_ne!(fold("Ørsted", &spec), fold("ørsted", &spec));
381    }
382
383    /// Full case folding, which `to_lowercase` is not. This is the only configuration that
384    /// actually exercises it — with diacritics folded too, several other rules could carry
385    /// the same fixture and it would prove nothing.
386    #[test]
387    fn full_case_folding_expands_the_sharp_s() {
388        let spec = FoldSpec {
389            diacritic_sensitive: true,
390            ..FoldSpec::default()
391        };
392        assert_eq!(fold("Straße", &spec), "strasse");
393        assert_eq!(fold("Straße", &spec), fold("STRASSE", &spec));
394        assert_eq!(fold("final", &spec), "final");
395        // Both sigmas fold together — a real bug for Greek prose under `to_lowercase`,
396        // which leaves final sigma alone in some positions.
397        assert_eq!(fold("ΟΔΟΣ", &spec), fold("οδος", &spec));
398    }
399
400    /// Arabic: the harakat are `Mn` and vanish with the accents; the letters no
401    /// decomposition reaches are in the table; the tatweel is decoration.
402    #[test]
403    fn arabic_orthographic_variants_fold_together() {
404        // Fully vocalised, with hamza — the marks go, and `أ` decomposes to `ا` + a mark.
405        assert_eq!(fold("أَحْمَد", &loose()), fold("احمد", &loose()));
406        // Alef wasla, which has NO canonical decomposition — this is the table's job.
407        assert_eq!(fold("ٱلكتاب", &loose()), fold("الكتاب", &loose()));
408        // Alef maksura / yeh, and teh marbuta / heh.
409        assert_eq!(fold("مصطفى", &loose()), fold("مصطفي", &loose()));
410        assert_eq!(fold("مدينة", &loose()), fold("مدينه", &loose()));
411        // Tatweel is pure typographic stretch.
412        assert_eq!(fold("كــتــاب", &loose()), fold("كتاب", &loose()));
413    }
414
415    /// Hebrew niqqud are `Mn` and fold away — but the geresh and gershayim are `Po` and
416    /// carry meaning, so they must **not**.
417    #[test]
418    fn hebrew_niqqud_fold_but_the_geresh_survives() {
419        assert_eq!(fold("שָׁלוֹם", &loose()), fold("שלום", &loose()));
420        assert!(
421            fold("צ\u{05F3}", &loose()).contains('\u{05F3}'),
422            "the geresh is punctuation, not a diacritic"
423        );
424    }
425
426    /// Turkish. `İstanbul` must be found by `istanbul`, and `kısa` must **not** be found by
427    /// `kisa` — they are different words.
428    #[test]
429    fn turkish_keeps_the_dotted_and_dotless_i_apart() {
430        assert_eq!(fold("İstanbul", &turkic()), fold("istanbul", &turkic()));
431        assert_eq!(fold("KISA", &turkic()), fold("kısa", &turkic()));
432        assert_ne!(
433            fold("kısa", &turkic()),
434            fold("kisa", &turkic()),
435            "in Turkish the dotless i is a different letter"
436        );
437        // The untailored fold deliberately merges them: a French reader searching a
438        // Turkish name should still find it.
439        assert_eq!(fold("kısa", &loose()), fold("kisa", &loose()));
440    }
441
442    /// The ordering trap. `İ` decomposes to `I` + COMBINING DOT ABOVE, so folding *after*
443    /// NFD would turn Turkish `İstanbul` into `ıstanbul` — the dotless letter, a different
444    /// word, never found. The tailoring must run on the composed char.
445    #[test]
446    fn a_decomposed_turkish_capital_i_folds_the_same_as_the_composed_one() {
447        let decomposed = "I\u{0307}stanbul";
448        assert_eq!(
449            decomposed.chars().count(),
450            9,
451            "the fixture must really be NFD"
452        );
453        assert_eq!(fold(decomposed, &turkic()), fold("İstanbul", &turkic()));
454        assert_eq!(fold(decomposed, &turkic()), "istanbul");
455    }
456
457    /// Prove the order over the **whole** `F`-status set (the 104 chars whose case fold is
458    /// more than one char), not the two anyone checks by hand: dropping marks after folding
459    /// must give the same answer as folding an already-decomposed char.
460    #[test]
461    fn folding_and_mark_stripping_commute_across_the_whole_f_status_set() {
462        let mut checked = 0;
463        for cp in 0u32..=0x10FFFF {
464            let Some(c) = char::from_u32(cp) else {
465                continue;
466            };
467            let expanded: String = std::iter::once(c).default_case_fold().collect();
468            if expanded.chars().count() < 2 {
469                continue;
470            }
471            checked += 1;
472
473            // Through the pipeline as one char...
474            let direct = fold(&c.to_string(), &loose());
475            // ...versus decomposing first and folding each piece.
476            let piecewise: String = c
477                .nfd()
478                .map(|d| fold(&d.to_string(), &loose()))
479                .collect::<Vec<_>>()
480                .concat();
481            assert_eq!(
482                direct, piecewise,
483                "U+{cp:04X} {c:?} folds differently depending on when it is decomposed"
484            );
485        }
486        assert_eq!(
487            checked, 104,
488            "the F-status set changed size — re-check the fold against the new Unicode data"
489        );
490    }
491
492    /// Diacritic-sensitive still folds *case*: the two toggles are independent.
493    #[test]
494    fn the_two_toggles_are_independent() {
495        let dia = FoldSpec {
496            diacritic_sensitive: true,
497            ..FoldSpec::default()
498        };
499        assert_eq!(fold("CAFÉ", &dia), fold("café", &dia));
500        assert_ne!(fold("cafe", &dia), fold("café", &dia));
501
502        let case = FoldSpec {
503            case_sensitive: true,
504            ..FoldSpec::default()
505        };
506        assert_eq!(fold("cafe", &case), fold("café", &case));
507        assert_ne!(fold("CAFE", &case), fold("cafe", &case));
508
509        let strict = FoldSpec {
510            case_sensitive: true,
511            diacritic_sensitive: true,
512            ..FoldSpec::default()
513        };
514        assert_eq!(fold("Café ΟΔΟΣ İ ß", &strict), "Café ΟΔΟΣ İ ß");
515    }
516
517    /// With both toggles on, the fold must be the **identity** — not merely equivalent.
518    ///
519    /// It is tempting to decompose anyway (canonical equivalence is free that way), and the
520    /// first cut of this did. But then a source `é` becomes two chars, every offset after it
521    /// shifts, and a caller that reasonably assumed "sensitive to everything" meant "the
522    /// text as I typed it" reads the wrong characters back. Checked over every char that
523    /// decomposes at all, not the handful anyone would think to try.
524    #[test]
525    fn the_strictest_fold_changes_nothing_at_all() {
526        let strict = FoldSpec {
527            case_sensitive: true,
528            diacritic_sensitive: true,
529            ..FoldSpec::default()
530        };
531        let mut checked = 0;
532        for cp in 0u32..=0x10FFFF {
533            let Some(c) = char::from_u32(cp) else {
534                continue;
535            };
536            if c.nfd().count() == 1 && std::iter::once(c).default_case_fold().count() == 1 {
537                continue; // nothing here to get wrong
538            }
539            checked += 1;
540            let s = c.to_string();
541            assert_eq!(
542                fold(&s, &strict),
543                s,
544                "U+{cp:04X} {c:?} was altered by a fold that must be the identity"
545            );
546        }
547        assert_eq!(
548            checked, 12253,
549            "the count of chars that decompose or multi-fold changed — the Unicode data \
550             moved under us, so re-check the fold against it"
551        );
552    }
553
554    /// Turkish uppercase is a different letter, and a case-preserving rename that ignored
555    /// that would silently rewrite Turkish prose into a different word.
556    #[test]
557    fn turkish_uppercase_keeps_the_dot() {
558        let mut out = String::new();
559        to_upper('i', FoldLocale::Turkic, &mut out);
560        assert_eq!(out, "İ");
561
562        let mut out = String::new();
563        to_upper('\u{0131}', FoldLocale::Turkic, &mut out);
564        assert_eq!(out, "I");
565
566        let mut out = String::new();
567        to_upper('i', FoldLocale::Root, &mut out);
568        assert_eq!(out, "I");
569    }
570
571    /// The ASCII fast path must be a **pure speedup**, not an approximation.
572    ///
573    /// It skips the NFD, the case-folding table, the general-category lookup and the letter
574    /// table on the grounds that all four are no-ops for ASCII. That reasoning is exactly the
575    /// kind that is right until it isn't — Turkish `I` is already one exception to it — so it
576    /// is checked rather than believed: every char in Unicode, under every combination of the
577    /// toggles, must fold identically with and without the shortcut.
578    #[test]
579    fn the_fast_path_agrees_with_the_general_path_on_every_char() {
580        let specs = [
581            FoldSpec::default(),
582            FoldSpec {
583                case_sensitive: true,
584                ..FoldSpec::default()
585            },
586            FoldSpec {
587                diacritic_sensitive: true,
588                ..FoldSpec::default()
589            },
590            FoldSpec {
591                case_sensitive: true,
592                diacritic_sensitive: true,
593                ..FoldSpec::default()
594            },
595            FoldSpec {
596                locale: FoldLocale::Turkic,
597                ..FoldSpec::default()
598            },
599            FoldSpec {
600                locale: FoldLocale::Turkic,
601                case_sensitive: true,
602                ..FoldSpec::default()
603            },
604        ];
605
606        // …and with the COMBINING DOT ABOVE as the lookahead too, since that is the one case
607        // where a fold consumes two source chars.
608        for next in [None, Some(COMBINING_DOT_ABOVE), Some('x')] {
609            for spec in &specs {
610                for cp in 0u32..=0x10FFFF {
611                    let Some(c) = char::from_u32(cp) else {
612                        continue;
613                    };
614
615                    let (mut fast, mut general) = (String::new(), String::new());
616                    let fast_consumed = fold_char(c, next, spec, &mut |g| fast.push(g));
617                    let general_consumed =
618                        fold_char_general(c, next, spec, &mut |g| general.push(g));
619
620                    assert_eq!(
621                        (fast, fast_consumed),
622                        (general, general_consumed),
623                        "U+{cp:04X} {c:?} folds differently on the fast path \
624                         (spec={spec:?}, next={next:?})"
625                    );
626                }
627            }
628        }
629    }
630
631    #[test]
632    fn a_malformed_language_tag_falls_back_to_the_root_locale() {
633        assert_eq!(FoldLocale::from_tag("tr"), FoldLocale::Turkic);
634        assert_eq!(FoldLocale::from_tag("tr-TR"), FoldLocale::Turkic);
635        assert_eq!(FoldLocale::from_tag("TR_tr"), FoldLocale::Turkic);
636        assert_eq!(FoldLocale::from_tag("az-Latn-AZ"), FoldLocale::Turkic);
637        assert_eq!(FoldLocale::from_tag("fr-FR"), FoldLocale::Root);
638        assert_eq!(FoldLocale::from_tag(""), FoldLocale::Root);
639        assert_eq!(FoldLocale::from_tag("-----"), FoldLocale::Root);
640        assert_eq!(FoldLocale::from_tag("nonsense!!"), FoldLocale::Root);
641    }
642}