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}