Skip to main content

azul_layout/text3/
script.rs

1//! Unicode script detection and language identification for text shaping
2//!
3// Taken from: https://github.com/greyblake/whatlang-rs/blob/master/src/scripts/detect.rs
4//
5// See: https://github.com/greyblake/whatlang-rs/pull/67
6
7// License:
8//
9// (The MIT License)
10//
11// Copyright (c) 2017 Sergey Potapov <blake131313@gmail.com>
12// Copyright (c) 2014 Titus Wormer <tituswormer@gmail.com>
13// Copyright (c) 2008 Kent S Johnson
14// Copyright (c) 2006 Jacob R Rideout <kde@jacobrideout.net>
15// Copyright (c) 2004 Maciej Ceglowski
16//
17// Permission is hereby granted, free of charge, to any person obtaining
18// a copy of this software and associated documentation files (the
19// 'Software'), to deal in the Software without restriction, including
20// without limitation the rights to use, copy, modify, merge, publish,
21// distribute, sublicense, and/or sell copies of the Software, and to
22// permit persons to whom the Software is furnished to do so, subject to
23// the following conditions:
24//
25// The above copyright notice and this permission notice shall be
26// included in all copies or substantial portions of the Software.
27//
28// THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND,
29// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
30// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
31// IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
32// CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
33// TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
34// SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
35
36#[cfg(feature = "text_layout_hyphenation")]
37use hyphenation::Language as HyphenationLanguage;
38#[cfg(feature = "text_layout_hyphenation")]
39pub use hyphenation::Language;
40
41/// Stub Language enum for when hyphenation is not enabled.
42/// This mirrors the variants used in script detection functions.
43#[cfg(not(feature = "text_layout_hyphenation"))]
44#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
45#[allow(dead_code)]
46pub enum Language {
47    // Latin script languages
48    EnglishUS,
49    French,
50    German1996,
51    Spanish,
52    Portuguese,
53    Estonian,
54    Hungarian,
55    Polish,
56    Czech,
57    Slovak,
58    Latvian,
59    Lithuanian,
60    Romanian,
61    Turkish,
62    Croatian,
63    Icelandic,
64    Welsh,
65    NorwegianBokmal,
66    Swedish,
67    // Cyrillic script languages
68    Russian,
69    Ukrainian,
70    Belarusian,
71    Bulgarian,
72    Macedonian,
73    SerbianCyrillic,
74    Mongolian,
75    SlavonicChurch,
76    // Greek script languages
77    GreekMono,
78    GreekPoly,
79    Coptic,
80    // Indic script languages
81    Hindi,
82    Bengali,
83    Assamese,
84    Marathi,
85    Sanskrit,
86    Gujarati,
87    Panjabi,
88    Kannada,
89    Malayalam,
90    Oriya,
91    Tamil,
92    Telugu,
93    // Other scripts
94    Georgian,
95    Ethiopic,
96    Thai,
97    Chinese,
98}
99
100#[derive(PartialEq, Eq, Debug, Clone, Copy)]
101pub enum Script {
102    // Keep this in alphabetic order (for C bindings)
103    Arabic,
104    Bengali,
105    Cyrillic,
106    Devanagari,
107    Ethiopic,
108    Georgian,
109    Greek,
110    Gujarati,
111    Gurmukhi,
112    Hangul,
113    Hebrew,
114    Hiragana,
115    Kannada,
116    Katakana,
117    Khmer,
118    Latin,
119    Malayalam,
120    Mandarin,
121    Myanmar,
122    Oriya,
123    Sinhala,
124    Tamil,
125    Telugu,
126    Thai,
127}
128
129// Is it space, punctuation or digit?
130// Stop character is a character that does not give any value for script
131// or language detection.
132#[inline]
133#[must_use] pub const fn is_stop_char(ch: char) -> bool {
134    matches!(ch, '\u{0000}'..='\u{0040}' | '\u{005B}'..='\u{0060}' | '\u{007B}'..='\u{007E}')
135}
136
137type ScriptChecker = (Script, fn(char) -> bool);
138type ScriptCounter = (Script, fn(char) -> bool, usize);
139
140const SCRIPT_CHECKERS: [ScriptChecker; 24] = [
141    (Script::Latin, is_latin),
142    (Script::Cyrillic, is_cyrillic),
143    (Script::Arabic, is_arabic),
144    (Script::Mandarin, is_mandarin),
145    (Script::Devanagari, is_devanagari),
146    (Script::Hebrew, is_hebrew),
147    (Script::Ethiopic, is_ethiopic),
148    (Script::Georgian, is_georgian),
149    (Script::Bengali, is_bengali),
150    (Script::Hangul, is_hangul),
151    (Script::Hiragana, is_hiragana),
152    (Script::Katakana, is_katakana),
153    (Script::Greek, is_greek),
154    (Script::Kannada, is_kannada),
155    (Script::Tamil, is_tamil),
156    (Script::Thai, is_thai),
157    (Script::Gujarati, is_gujarati),
158    (Script::Gurmukhi, is_gurmukhi),
159    (Script::Telugu, is_telugu),
160    (Script::Malayalam, is_malayalam),
161    (Script::Oriya, is_oriya),
162    (Script::Myanmar, is_myanmar),
163    (Script::Sinhala, is_sinhala),
164    (Script::Khmer, is_khmer),
165];
166
167/// Detect only a script by a given text
168/// # Panics
169///
170/// Panics only if the internal script-counter table were empty, which cannot happen (it is a fixed-size array).
171pub fn detect_script(text: &str) -> Option<Script> {
172    let mut script_counters: [ScriptCounter; 24] = SCRIPT_CHECKERS.map(|(s, f)| (s, f, 0));
173
174    let half = text.chars().count() / 2;
175
176    for ch in text.chars() {
177        if is_stop_char(ch) {
178            continue;
179        }
180
181        // For performance reasons, we need to mutate script_counters by calling
182        // `swap` function, it would not be possible to do using normal iterator.
183        for i in 0..script_counters.len() {
184            let found = {
185                let (script, check_fn, ref mut count) = script_counters[i];
186                if check_fn(ch) {
187                    *count += 1;
188                    if *count > half {
189                        return Some(script);
190                    }
191                    true
192                } else {
193                    false
194                }
195            };
196            // Have to let borrow of count fall out of scope before doing swapping, or we could
197            // do this above.
198            if found {
199                // If script was found, move it closer to the front.
200                // If the text contains largely 1 or 2 scripts, this will
201                // cause these scripts to be eventually checked first.
202                if i > 0 {
203                    script_counters.swap(i - 1, i);
204                }
205                break;
206            }
207        }
208    }
209
210    let (script, _, count) = script_counters
211        .iter()
212        .copied()
213        .max_by_key(|&(_, _, count)| count)
214        .unwrap();
215    if count != 0 {
216        Some(script)
217    } else {
218        None
219    }
220}
221
222#[must_use] pub fn detect_char_script(ch: char) -> Option<Script> {
223    for &(script, check_fn) in &SCRIPT_CHECKERS {
224        if check_fn(ch) {
225            return Some(script);
226        }
227    }
228    None
229}
230
231/// Iterates through the text once and returns as soon as an Assamese-specific character is found.
232fn detect_bengali_language(text: &str) -> Language {
233    for c in text.chars() {
234        // These characters are specific to Assamese in the Bengali script block.
235        // We can return immediately as this is the highest priority check.
236        if matches!(c, '\u{09F0}' | '\u{09F1}') {
237            // ৰ, ৱ
238            return Language::Assamese;
239        }
240    }
241    // If we finish the loop without finding any Assamese characters, it's Bengali.
242    Language::Bengali
243}
244
245fn detect_cyrillic_language(text: &str) -> Language {
246    for c in text.chars() {
247        match c {
248            // Highest priority: Old Cyrillic characters for Slavonic Church. Return immediately.
249            '\u{0460}'..='\u{047F}' => return Language::SlavonicChurch,
250            // Set flags for other languages. We don't return yet because a higher-priority
251            // character (like the one above) could still appear.
252            'ѓ' | 'ќ' | 'ѕ' => return Language::Macedonian,
253            'ў' => return Language::Belarusian,
254            'є' | 'і' | 'ї' | 'ґ' => return Language::Ukrainian,
255            'ө' | 'ү' | 'һ' => return Language::Mongolian,
256            'ј' | 'љ' | 'њ' | 'ћ' | 'ђ' | 'џ' => return Language::SerbianCyrillic,
257            // Bulgarian 'ъ' is also in Russian, but 'щ' is a stronger indicator.
258            // The logic implies that if either is present, it might be Bulgarian.
259            'щ' => return Language::Bulgarian,
260            _ => {}
261        }
262    }
263
264    Language::Russian
265}
266
267fn detect_devanagari_language(text: &str) -> Language {
268    for c in text.chars() {
269        match c {
270            // Marathi has higher priority in the original logic. Return immediately.
271            '\u{0933}' => return Language::Marathi, // ळ
272            // Flag for Sanskrit Vedic extensions.
273            '\u{1CD0}'..='\u{1CFF}' => return Language::Sanskrit,
274            _ => (),
275        }
276    }
277
278    Language::Hindi
279}
280
281fn detect_greek_language(text: &str) -> Language {
282    for c in text.chars() {
283        match c {
284            // Coptic has higher priority. Return immediately.
285            '\u{2C80}'..='\u{2CFF}' => return Language::Coptic,
286            // Flag for Greek Extended (Polytonic) characters.
287            '\u{1F00}'..='\u{1FFF}' => return Language::GreekPoly,
288            _ => {}
289        }
290    }
291
292    Language::GreekMono
293}
294
295#[allow(clippy::match_same_arms)] // enum/value mapping/dispatch table: one arm per input variant (or cross-type bindings that can't merge)
296fn detect_latin_language(text: &str) -> Language {
297    // Flags for languages checked near the end of the original if-else chain.
298    let mut has_french_c = false;
299    let mut has_portuguese_o = false;
300    let mut has_portuguese_a = false;
301
302    for c in text.chars() {
303        match c {
304            // --- Early Return Cases (in order of priority) ---
305            'ß' => return Language::German1996,
306            'ő' | 'ű' => return Language::Hungarian,
307            'ł' => return Language::Polish,
308            'ř' | 'ů' => return Language::Czech,
309            'ľ' | 'ĺ' | 'ŕ' => return Language::Slovak,
310            'ā' | 'ē' | 'ģ' | 'ī' | 'ķ' | 'ļ' | 'ņ' | 'ō' | 'ū' => {
311                return Language::Latvian
312            }
313            'ą' | 'ę' | 'ė' | 'į' | 'ų' => return Language::Lithuanian,
314            'ă' | 'ș' | 'ț' => return Language::Romanian,
315            'ğ' | 'ı' | 'ş' => return Language::Turkish,
316            'đ' => return Language::Croatian, /* Also used in Vietnamese, but Croatian is the */
317            // original's intent
318            'þ' | 'ð' => return Language::Icelandic,
319            'ŵ' | 'ŷ' => return Language::Welsh,
320            'æ' | 'ø' => return Language::NorwegianBokmal, // And Danish
321            'å' => return Language::Swedish,               // And Norwegian, Finnish
322            'ñ' => return Language::Spanish,
323            'ä' | 'ö' | 'ü' => return Language::German1996,
324
325            // NOTE: 'õ' is used by both Estonian and Portuguese
326            // Since Estonian is checked first, it takes precedence.
327            'õ' => has_portuguese_o = true,
328            'ã' => has_portuguese_a = true,
329
330            // --- Flag-setting Cases ---
331            'ç' => has_french_c = true, // Also in Portuguese
332            'á' | 'é' | 'í' | 'ó' | 'ú' => return Language::Spanish,
333
334            _ => (),
335        }
336    }
337
338    // decide between portuguese, estonian and french
339
340    if has_french_c && !has_portuguese_o && !has_portuguese_a {
341        return Language::French;
342    }
343
344    if has_portuguese_o && !has_french_c && !has_portuguese_a {
345        return Language::Estonian;
346    }
347
348    if has_portuguese_o || has_portuguese_a || has_french_c {
349        return Language::Portuguese;
350    }
351
352    Language::EnglishUS
353}
354
355#[allow(clippy::match_same_arms)] // enum/value mapping/dispatch table: one arm per input variant (or cross-type bindings that can't merge)
356#[must_use] pub fn script_to_language(script: Script, text: &str) -> Language {
357    match script {
358        Script::Ethiopic => Language::Ethiopic,
359        Script::Georgian => Language::Georgian,
360        Script::Gujarati => Language::Gujarati,
361        Script::Gurmukhi => Language::Panjabi,
362        Script::Kannada => Language::Kannada,
363        Script::Malayalam => Language::Malayalam,
364        Script::Mandarin => Language::Chinese,
365        Script::Oriya => Language::Oriya,
366        Script::Tamil => Language::Tamil,
367        Script::Telugu => Language::Telugu,
368        Script::Thai => Language::Thai,
369        Script::Bengali => detect_bengali_language(text),
370        Script::Cyrillic => detect_cyrillic_language(text),
371        Script::Devanagari => detect_devanagari_language(text),
372        Script::Greek => detect_greek_language(text),
373        Script::Latin => detect_latin_language(text),
374
375        // not directly matchable
376        Script::Myanmar => Language::Thai,
377        Script::Khmer => Language::Thai,
378        Script::Sinhala => Language::Hindi,
379
380        // no classical hyphenation behaviour
381        Script::Arabic => Language::Chinese,
382        Script::Hebrew => Language::Chinese,
383        Script::Hangul => Language::Chinese,
384        Script::Hiragana => Language::Chinese,
385        Script::Katakana => Language::Chinese,
386    }
387}
388
389#[must_use] pub const fn is_cyrillic(ch: char) -> bool {
390    matches!(ch,
391        '\u{0400}'..='\u{0484}'
392        | '\u{0487}'..='\u{052F}'
393        | '\u{2DE0}'..='\u{2DFF}'
394        | '\u{A640}'..='\u{A69D}'
395        | '\u{1D2B}'
396        | '\u{1D78}'
397        | '\u{A69F}'
398    )
399}
400
401// https://en.wikipedia.org/wiki/Latin_script_in_Unicode
402#[must_use] pub const fn is_latin(ch: char) -> bool {
403    matches!(ch,
404        'a'..='z'
405        | 'A'..='Z'
406        | '\u{0080}'..='\u{00FF}'
407        | '\u{0100}'..='\u{017F}'
408        | '\u{0180}'..='\u{024F}'
409        | '\u{0250}'..='\u{02AF}'
410        | '\u{1D00}'..='\u{1D7F}'
411        | '\u{1D80}'..='\u{1DBF}'
412        | '\u{1E00}'..='\u{1EFF}'
413        | '\u{2100}'..='\u{214F}'
414        | '\u{2C60}'..='\u{2C7F}'
415        | '\u{A720}'..='\u{A7FF}'
416        | '\u{AB30}'..='\u{AB6F}'
417    )
418}
419
420// Based on https://en.wikipedia.org/wiki/Arabic_script_in_Unicode
421#[must_use] pub const fn is_arabic(ch: char) -> bool {
422    matches!(ch,
423        '\u{0600}'..='\u{06FF}'
424        | '\u{0750}'..='\u{07FF}'
425        | '\u{08A0}'..='\u{08FF}'
426        | '\u{FB50}'..='\u{FDFF}'
427        | '\u{FE70}'..='\u{FEFF}'
428        | '\u{10E60}'..='\u{10E7F}'
429        | '\u{1EE00}'..='\u{1EEFF}'
430    )
431}
432
433// Based on https://en.wikipedia.org/wiki/Devanagari#Unicode
434#[must_use] pub const fn is_devanagari(ch: char) -> bool {
435    matches!(ch, '\u{0900}'..='\u{097F}' | '\u{A8E0}'..='\u{A8FF}' | '\u{1CD0}'..='\u{1CFF}')
436}
437
438// Based on https://www.key-shortcut.com/en/writing-systems/ethiopian-script/
439#[must_use] pub const fn is_ethiopic(ch: char) -> bool {
440    matches!(ch, '\u{1200}'..='\u{139F}' | '\u{2D80}'..='\u{2DDF}' | '\u{AB00}'..='\u{AB2F}')
441}
442
443// Based on https://en.wikipedia.org/wiki/Hebrew_(Unicode_block)
444#[must_use] pub const fn is_hebrew(ch: char) -> bool {
445    matches!(ch, '\u{0590}'..='\u{05FF}')
446}
447
448#[must_use] pub const fn is_georgian(ch: char) -> bool {
449    matches!(ch, '\u{10A0}'..='\u{10FF}')
450}
451
452#[must_use] pub const fn is_mandarin(ch: char) -> bool {
453    matches!(ch,
454        '\u{2E80}'..='\u{2E99}'
455        | '\u{2E9B}'..='\u{2EF3}'
456        | '\u{2F00}'..='\u{2FD5}'
457        | '\u{3005}'
458        | '\u{3007}'
459        | '\u{3021}'..='\u{3029}'
460        | '\u{3038}'..='\u{303B}'
461        | '\u{3400}'..='\u{4DB5}'
462        | '\u{4E00}'..='\u{9FCC}'
463        | '\u{F900}'..='\u{FA6D}'
464        | '\u{FA70}'..='\u{FAD9}'
465    )
466}
467
468#[must_use] pub const fn is_bengali(ch: char) -> bool {
469    matches!(ch, '\u{0980}'..='\u{09FF}')
470}
471
472#[must_use] pub const fn is_hiragana(ch: char) -> bool {
473    matches!(ch, '\u{3040}'..='\u{309F}')
474}
475
476#[must_use] pub const fn is_katakana(ch: char) -> bool {
477    matches!(ch,
478        '\u{30A0}'..='\u{30FF}'
479        // Halfwidth Katakana (part of the Halfwidth and Fullwidth Forms block).
480        // U+FF66..=FF9F are katakana; U+FF61..=FF65 are halfwidth CJK punctuation.
481        | '\u{FF66}'..='\u{FF9F}'
482    )
483}
484
485// Hangul is Korean Alphabet. Unicode ranges are taken from: https://en.wikipedia.org/wiki/Hangul
486#[must_use] pub const fn is_hangul(ch: char) -> bool {
487    matches!(ch,
488        '\u{AC00}'..='\u{D7AF}'
489        | '\u{1100}'..='\u{11FF}'
490        | '\u{3130}'..='\u{318F}'
491        | '\u{A960}'..='\u{A97F}'
492        | '\u{D7B0}'..='\u{D7FF}'
493        // Halfwidth Hangul variants only. The rest of the Halfwidth and Fullwidth
494        // Forms block (U+FF00..=FF60 fullwidth ASCII/Latin, U+FF61..=FF9F halfwidth
495        // katakana/punct, U+FFE0..=FFEF fullwidth/halfwidth symbols) and Enclosed CJK
496        // Letters and Months (U+3200..=32FF) are NOT Hangul and were previously
497        // swallowed here, misclassifying halfwidth kana and fullwidth Latin as Hangul.
498        | '\u{FFA0}'..='\u{FFDC}'
499    )
500}
501
502// Taken from: https://en.wikipedia.org/wiki/Greek_and_Coptic
503#[must_use] pub const fn is_greek(ch: char) -> bool {
504    matches!(ch, '\u{0370}'..='\u{03FF}')
505}
506
507// Based on: https://en.wikipedia.org/wiki/Kannada_(Unicode_block)
508#[must_use] pub const fn is_kannada(ch: char) -> bool {
509    matches!(ch, '\u{0C80}'..='\u{0CFF}')
510}
511
512// Based on: https://en.wikipedia.org/wiki/Tamil_(Unicode_block)
513#[must_use] pub const fn is_tamil(ch: char) -> bool {
514    matches!(ch, '\u{0B80}'..='\u{0BFF}')
515}
516
517// Based on: https://en.wikipedia.org/wiki/Thai_(Unicode_block)
518#[must_use] pub const fn is_thai(ch: char) -> bool {
519    matches!(ch, '\u{0E00}'..='\u{0E7F}')
520}
521
522// Based on: https://en.wikipedia.org/wiki/Gujarati_(Unicode_block)
523#[must_use] pub const fn is_gujarati(ch: char) -> bool {
524    matches!(ch, '\u{0A80}'..='\u{0AFF}')
525}
526
527// Gurmukhi is the script for Punjabi language.
528// Based on: https://en.wikipedia.org/wiki/Gurmukhi_(Unicode_block)
529#[must_use] pub const fn is_gurmukhi(ch: char) -> bool {
530    matches!(ch, '\u{0A00}'..='\u{0A7F}')
531}
532
533#[must_use] pub const fn is_telugu(ch: char) -> bool {
534    matches!(ch, '\u{0C00}'..='\u{0C7F}')
535}
536
537// Based on: https://en.wikipedia.org/wiki/Malayalam_(Unicode_block)
538#[must_use] pub const fn is_malayalam(ch: char) -> bool {
539    matches!(ch, '\u{0D00}'..='\u{0D7F}')
540}
541
542// Based on: https://en.wikipedia.org/wiki/Oriya_(Unicode_block)
543#[must_use] pub const fn is_oriya(ch: char) -> bool {
544    matches!(ch, '\u{0B00}'..='\u{0B7F}')
545}
546
547// Based on: https://en.wikipedia.org/wiki/Myanmar_(Unicode_block)
548#[must_use] pub const fn is_myanmar(ch: char) -> bool {
549    matches!(ch, '\u{1000}'..='\u{109F}')
550}
551
552// Based on: https://en.wikipedia.org/wiki/Sinhala_(Unicode_block)
553#[must_use] pub const fn is_sinhala(ch: char) -> bool {
554    matches!(ch, '\u{0D80}'..='\u{0DFF}')
555}
556
557// Based on: https://en.wikipedia.org/wiki/Khmer_alphabet
558#[must_use] pub const fn is_khmer(ch: char) -> bool {
559    matches!(ch, '\u{1780}'..='\u{17FF}' | '\u{19E0}'..='\u{19FF}')
560}
561
562#[cfg(test)]
563mod script_class_tests {
564    use super::{detect_script, is_hangul, is_katakana, Script};
565
566    #[test]
567    fn halfwidth_katakana_is_not_hangul() {
568        // U+FF71..FF73 = halfwidth katakana アイウ — must classify as Katakana, not Hangul.
569        for ch in ['\u{FF71}', '\u{FF72}', '\u{FF73}'] {
570            assert!(!is_hangul(ch), "{ch:?} wrongly matched is_hangul");
571            assert!(is_katakana(ch), "{ch:?} should match is_katakana");
572        }
573        assert_eq!(detect_script("\u{FF71}\u{FF72}\u{FF73}"), Some(Script::Katakana));
574    }
575
576    #[test]
577    fn fullwidth_latin_is_not_hangul() {
578        // U+FF21..FF23 = fullwidth ABC — must not be classified as Hangul.
579        for ch in ['\u{FF21}', '\u{FF22}', '\u{FF23}'] {
580            assert!(!is_hangul(ch), "{ch:?} wrongly matched is_hangul");
581        }
582        assert_ne!(detect_script("\u{FF21}\u{FF22}\u{FF23}"), Some(Script::Hangul));
583    }
584
585    #[test]
586    fn real_hangul_still_detected() {
587        assert!(is_hangul('\u{AC00}')); // 가
588        assert_eq!(detect_script("\u{AC00}\u{AC01}"), Some(Script::Hangul));
589    }
590}
591
592#[cfg(test)]
593#[allow(clippy::unicode_not_nfc, clippy::non_ascii_literal)]
594mod autotest_generated {
595    use super::*;
596
597    // ---------------------------------------------------------------------
598    // Helpers
599    // ---------------------------------------------------------------------
600
601    /// Every `Script` variant, in declaration order.
602    const ALL_SCRIPTS: [Script; 24] = [
603        Script::Arabic,
604        Script::Bengali,
605        Script::Cyrillic,
606        Script::Devanagari,
607        Script::Ethiopic,
608        Script::Georgian,
609        Script::Greek,
610        Script::Gujarati,
611        Script::Gurmukhi,
612        Script::Hangul,
613        Script::Hebrew,
614        Script::Hiragana,
615        Script::Kannada,
616        Script::Katakana,
617        Script::Khmer,
618        Script::Latin,
619        Script::Malayalam,
620        Script::Mandarin,
621        Script::Myanmar,
622        Script::Oriya,
623        Script::Sinhala,
624        Script::Tamil,
625        Script::Telugu,
626        Script::Thai,
627    ];
628
629    /// `Script` has no `Hash`/`Ord`, so index it by hand for set-like bookkeeping.
630    fn script_index(s: Script) -> usize {
631        ALL_SCRIPTS
632            .iter()
633            .position(|&x| x == s)
634            .expect("ALL_SCRIPTS must list every Script variant")
635    }
636
637    /// The first checker in `SCRIPT_CHECKERS` that claims `ch` — i.e. exactly what
638    /// both `detect_char_script` and (for a 1-char text) `detect_script` must return.
639    fn first_checker_hit(ch: char) -> Option<Script> {
640        SCRIPT_CHECKERS
641            .iter()
642            .find(|(_, check_fn)| check_fn(ch))
643            .map(|&(script, _)| script)
644    }
645
646    /// Iterate every scalar value in the BMP (surrogates are not `char`s).
647    fn bmp_chars() -> impl Iterator<Item = char> {
648        (0u32..=0xFFFF).filter_map(char::from_u32)
649    }
650
651    /// Deterministic pseudo-random scalar values — no `rand` dependency, and the
652    /// sequence is identical on every run so a failure is always reproducible.
653    fn lcg_chars(count: usize, seed: u64) -> String {
654        let mut state = seed;
655        let mut out = String::with_capacity(count * 4);
656        let mut pushed = 0usize;
657        while pushed < count {
658            state = state
659                .wrapping_mul(6_364_136_223_846_793_005)
660                .wrapping_add(1_442_695_040_888_963_407);
661            let cp = (state >> 16) as u32 % 0x0011_0000;
662            if let Some(ch) = char::from_u32(cp) {
663                out.push(ch);
664                pushed += 1;
665            }
666        }
667        out
668    }
669
670    // ---------------------------------------------------------------------
671    // is_stop_char (predicate)
672    // ---------------------------------------------------------------------
673
674    // Const-evaluability is part of the API: these must fold at compile time.
675    const _: bool = is_stop_char(' ');
676    const _: bool = is_latin('a');
677    const _: bool = is_khmer('\u{1780}');
678
679    #[test]
680    fn is_stop_char_basic_true_false() {
681        for ch in [
682            '\u{0000}', '\t', '\n', '\r', ' ', '!', '0', '9', '@', '[', '\\', ']', '^', '_', '`',
683            '{', '|', '}', '~',
684        ] {
685            assert!(is_stop_char(ch), "{ch:?} should be a stop char");
686        }
687        for ch in ['a', 'z', 'A', 'Z', '\u{007F}', 'é', 'あ', 'م', '\u{10FFFF}'] {
688            assert!(!is_stop_char(ch), "{ch:?} should not be a stop char");
689        }
690    }
691
692    #[test]
693    fn is_stop_char_range_boundaries_are_exact() {
694        // '\u{0000}'..='\u{0040}'
695        assert!(is_stop_char('\u{0040}'));
696        assert!(!is_stop_char('\u{0041}')); // 'A' — first char past the first range
697        // '\u{005B}'..='\u{0060}'
698        assert!(!is_stop_char('\u{005A}')); // 'Z'
699        assert!(is_stop_char('\u{005B}'));
700        assert!(is_stop_char('\u{0060}'));
701        assert!(!is_stop_char('\u{0061}')); // 'a'
702        // '\u{007B}'..='\u{007E}'
703        assert!(!is_stop_char('\u{007A}')); // 'z'
704        assert!(is_stop_char('\u{007B}'));
705        assert!(is_stop_char('\u{007E}'));
706        // U+007F (DEL) is deliberately *outside* every stop range and every script
707        // range: it is counted toward `half` in detect_script but never scores.
708        assert!(!is_stop_char('\u{007F}'));
709        assert_eq!(detect_char_script('\u{007F}'), None);
710        assert!(!is_stop_char('\u{0080}'));
711    }
712
713    #[test]
714    fn stop_chars_never_carry_a_script() {
715        // Invariant the whole detector rests on: a stop char scores for nothing,
716        // so a stop-only text can never produce a Some(script).
717        for ch in bmp_chars() {
718            if is_stop_char(ch) {
719                assert_eq!(
720                    detect_char_script(ch),
721                    None,
722                    "stop char {ch:?} (U+{:04X}) also matched a script checker",
723                    ch as u32
724                );
725            }
726        }
727    }
728
729    // ---------------------------------------------------------------------
730    // detect_script (parser)
731    // ---------------------------------------------------------------------
732
733    #[test]
734    fn detect_script_empty_input_returns_none() {
735        assert_eq!(detect_script(""), None);
736    }
737
738    #[test]
739    fn detect_script_whitespace_only_returns_none() {
740        for text in ["   ", "\t\n", "\r\n\r\n", "\t \t \n", "\u{0000}\u{0000}"] {
741            assert_eq!(detect_script(text), None, "whitespace {text:?}");
742        }
743    }
744
745    #[test]
746    fn detect_script_non_ascii_whitespace_scores_nothing() {
747        // U+2003 EM SPACE / U+2028 LINE SEPARATOR are *not* stop chars (they are
748        // above U+007E) but no checker claims them either — so still None.
749        assert_eq!(detect_script("\u{2003}\u{2003}"), None);
750        assert_eq!(detect_script("\u{2028}"), None);
751    }
752
753    #[test]
754    fn detect_script_garbage_returns_none_without_panicking() {
755        for text in [
756            "\u{0001}\u{0002}\u{0003}",
757            "\u{007F}\u{007F}\u{007F}",
758            "!@#$%^&*()_+-=[]{}|;':\",./<>?",
759            "\u{FFFD}\u{FFFD}",
760            "\u{200B}\u{200C}\u{200D}", // zero-width space / non-joiner / joiner
761        ] {
762            assert_eq!(detect_script(text), None, "garbage {text:?}");
763        }
764    }
765
766    #[test]
767    fn detect_script_boundary_number_strings() {
768        // Digits, signs and dots are all stop chars → nothing to score.
769        for text in [
770            "0",
771            "-0",
772            "9223372036854775807",  // i64::MAX
773            "-9223372036854775808", // i64::MIN
774            "18446744073709551615", // u64::MAX
775            "1e309",                // f64 overflow literal — 'e' is Latin though
776            "0.0000000000000000001",
777        ] {
778            let got = detect_script(text);
779            let expected = if text.chars().any(|c| c.is_ascii_alphabetic()) {
780                Some(Script::Latin)
781            } else {
782                None
783            };
784            assert_eq!(got, expected, "numeric text {text:?}");
785        }
786        // "NaN" / "inf" are pure ASCII letters → Latin, not a crash.
787        assert_eq!(detect_script("NaN"), Some(Script::Latin));
788        assert_eq!(detect_script("inf"), Some(Script::Latin));
789        assert_eq!(detect_script("-inf"), Some(Script::Latin));
790    }
791
792    #[test]
793    fn detect_script_leading_trailing_junk_is_skipped() {
794        assert_eq!(detect_script("  hello  "), Some(Script::Latin));
795        assert_eq!(detect_script("valid;garbage"), Some(Script::Latin));
796        assert_eq!(detect_script("\t\nПривет\t\n"), Some(Script::Cyrillic));
797    }
798
799    #[test]
800    fn detect_script_minority_script_still_wins_over_stop_chars() {
801        // "a!!!!!!!!" is 9 chars → half == 4, so the single Latin char never crosses
802        // the early-exit threshold. It must still win via the max-count fallback
803        // (count != 0), not fall through to None.
804        assert_eq!(detect_script("a!!!!!!!!"), Some(Script::Latin));
805        assert_eq!(detect_script("!!!!!!!!!"), None);
806    }
807
808    #[test]
809    fn detect_script_unicode_input_does_not_panic() {
810        for text in [
811            "\u{1F600}",                           // emoji, matches no script
812            "\u{1F600}\u{1F468}\u{200D}\u{1F469}", // ZWJ sequence
813            "e\u{0301}",                           // 'e' + combining acute
814            "\u{0301}\u{0302}\u{0303}",            // bare combining marks
815            "\u{10FFFF}",                          // char::MAX
816            "\u{FFFF}",                            // BMP noncharacter
817        ] {
818            let got = detect_script(text);
819            assert_eq!(got, detect_script(text), "not deterministic for {text:?}");
820        }
821        assert_eq!(detect_script("\u{1F600}"), None);
822        assert_eq!(detect_script("\u{0301}\u{0302}"), None);
823        assert_eq!(detect_script("\u{10FFFF}"), None);
824        assert_eq!(detect_script("e\u{0301}"), Some(Script::Latin));
825    }
826
827    #[test]
828    fn detect_script_deeply_nested_brackets_do_not_stack_overflow() {
829        // 10_000 nested brackets: the detector is iterative, and every bracket is a
830        // stop char, so this must terminate with None rather than recursing.
831        let depth = 10_000;
832        let mut text = String::with_capacity(depth * 2);
833        for _ in 0..depth {
834            text.push('(');
835        }
836        for _ in 0..depth {
837            text.push(')');
838        }
839        assert_eq!(detect_script(&text), None);
840
841        let mut nested = String::new();
842        for _ in 0..depth {
843            nested.push_str("{[");
844        }
845        for _ in 0..depth {
846            nested.push_str("]}");
847        }
848        assert_eq!(detect_script(&nested), None);
849    }
850
851    #[test]
852    fn detect_script_extremely_long_input_terminates() {
853        // 1M identical Latin chars: must early-exit once count > half.
854        let long_latin = "a".repeat(1_000_000);
855        assert_eq!(detect_script(&long_latin), Some(Script::Latin));
856
857        // 200k chars that match *no* checker: worst case — all 24 checkers run for
858        // every char and there is no early exit. Must still finish, returning None.
859        let long_junk = "\u{1F600}".repeat(200_000);
860        assert_eq!(detect_script(&long_junk), None);
861
862        // 200k stop chars: skipped, but still walked.
863        let long_stops = " ".repeat(200_000);
864        assert_eq!(detect_script(&long_stops), None);
865    }
866
867    #[test]
868    fn detect_script_long_mixed_script_input_is_deterministic() {
869        // Alternating scripts defeat the "move winner to the front" heuristic and
870        // never cross the half threshold. It must still terminate and be stable.
871        let mixed = "aб".repeat(100_000);
872        let first = detect_script(&mixed);
873        let second = detect_script(&mixed);
874        assert_eq!(first, second, "detect_script is not deterministic");
875        assert!(
876            first == Some(Script::Latin) || first == Some(Script::Cyrillic),
877            "expected one of the two present scripts, got {first:?}"
878        );
879    }
880
881    #[test]
882    fn detect_script_pseudo_random_garbage_never_panics() {
883        for seed in [1u64, 0xDEAD_BEEF, u64::MAX] {
884            let text = lcg_chars(5_000, seed);
885            let first = detect_script(&text);
886            let second = detect_script(&text);
887            assert_eq!(first, second, "non-deterministic for seed {seed}");
888        }
889    }
890
891    #[test]
892    fn detect_script_majority_wins() {
893        assert_eq!(detect_script("aaaaaб"), Some(Script::Latin));
894        assert_eq!(detect_script("бббббa"), Some(Script::Cyrillic));
895        // Latin body with a couple of CJK chars mixed in.
896        assert_eq!(detect_script("hello 世界"), Some(Script::Latin));
897        assert_eq!(detect_script("世界世界 hi"), Some(Script::Mandarin));
898    }
899
900    #[test]
901    fn detect_script_valid_minimal_positive_controls() {
902        let cases: [(&str, Script); 16] = [
903            ("hello", Script::Latin),
904            ("Привет", Script::Cyrillic),
905            ("مرحبا", Script::Arabic),
906            ("你好世界", Script::Mandarin),
907            ("नमस्ते", Script::Devanagari),
908            ("שלום", Script::Hebrew),
909            ("ሰላም", Script::Ethiopic),
910            ("გამარჯობა", Script::Georgian),
911            ("আমার", Script::Bengali),
912            ("안녕하세요", Script::Hangul),
913            ("こんにちは", Script::Hiragana),
914            ("カタカナ", Script::Katakana),
915            ("Γειά", Script::Greek),
916            ("ಕನ್ನಡ", Script::Kannada),
917            ("தமிழ்", Script::Tamil),
918            ("สวัสดี", Script::Thai),
919        ];
920        for (text, expected) in cases {
921            assert_eq!(detect_script(text), Some(expected), "text {text:?}");
922        }
923    }
924
925    #[test]
926    fn detect_script_is_pure_no_state_leaks_between_calls() {
927        // detect_script mutates (swaps) its counter table; that table must be local.
928        // Priming it with Cyrillic must not change the verdict for a later Latin text.
929        assert_eq!(detect_script("ббббб"), Some(Script::Cyrillic));
930        assert_eq!(detect_script("aaaaa"), Some(Script::Latin));
931        assert_eq!(detect_script("ббббб"), Some(Script::Cyrillic));
932        assert_eq!(detect_script("aaaaa"), Some(Script::Latin));
933    }
934
935    #[test]
936    fn detect_script_single_char_agrees_with_detect_char_script() {
937        // Strong cross-check over the whole BMP: a 1-char text has half == 0, so the
938        // first checker that claims the char wins immediately — exactly what
939        // detect_char_script returns. Any divergence is a table-ordering bug.
940        for ch in bmp_chars() {
941            let expected = first_checker_hit(ch);
942            assert_eq!(
943                detect_char_script(ch),
944                expected,
945                "detect_char_script disagrees with SCRIPT_CHECKERS for U+{:04X}",
946                ch as u32
947            );
948            let text = ch.to_string();
949            assert_eq!(
950                detect_script(&text),
951                expected,
952                "detect_script disagrees with detect_char_script for U+{:04X}",
953                ch as u32
954            );
955        }
956    }
957
958    // ---------------------------------------------------------------------
959    // detect_char_script (dispatch table)
960    // ---------------------------------------------------------------------
961
962    #[test]
963    fn detect_char_script_extreme_inputs() {
964        assert_eq!(detect_char_script('\u{0000}'), None);
965        assert_eq!(detect_char_script('\u{10FFFF}'), None); // char::MAX
966        assert_eq!(detect_char_script(char::MAX), None);
967        assert_eq!(detect_char_script('\u{FFFF}'), None); // noncharacter
968        assert_eq!(detect_char_script('\u{E000}'), None); // private use area
969        assert_eq!(detect_char_script('a'), Some(Script::Latin));
970        assert_eq!(detect_char_script('\u{1EE00}'), Some(Script::Arabic)); // astral Arabic
971        assert_eq!(detect_char_script('\u{10E60}'), Some(Script::Arabic)); // Rumi digits
972    }
973
974    #[test]
975    fn detect_char_script_astral_planes_agree_with_the_table() {
976        // The two astral Arabic ranges plus the surrounding gaps, which the BMP
977        // sweep above cannot reach.
978        for cp in (0x1_0E00u32..=0x1_0F00).chain(0x1_ED00..=0x1_EF00).chain([
979            0x1_F600, 0x2_0000, 0x10_FFFF,
980        ]) {
981            let Some(ch) = char::from_u32(cp) else {
982                continue;
983            };
984            assert_eq!(
985                detect_char_script(ch),
986                first_checker_hit(ch),
987                "astral U+{cp:05X} disagrees with SCRIPT_CHECKERS"
988            );
989            assert_eq!(
990                detect_script(&ch.to_string()),
991                first_checker_hit(ch),
992                "astral U+{cp:05X}: detect_script != detect_char_script"
993            );
994        }
995    }
996
997    #[test]
998    fn detect_char_script_none_implies_no_checker_matched() {
999        for ch in bmp_chars() {
1000            if detect_char_script(ch).is_none() {
1001                for (script, check_fn) in SCRIPT_CHECKERS {
1002                    assert!(
1003                        !check_fn(ch),
1004                        "U+{:04X} is unclassified yet {script:?}'s checker claims it",
1005                        ch as u32
1006                    );
1007                }
1008            }
1009        }
1010    }
1011
1012    #[test]
1013    fn detect_char_script_some_implies_that_scripts_checker_matched() {
1014        for ch in bmp_chars() {
1015            if let Some(script) = detect_char_script(ch) {
1016                let (_, check_fn) = SCRIPT_CHECKERS[script_index_in_table(script)];
1017                assert!(
1018                    check_fn(ch),
1019                    "detect_char_script said {script:?} for U+{:04X} but its checker says no",
1020                    ch as u32
1021                );
1022            }
1023        }
1024    }
1025
1026    fn script_index_in_table(script: Script) -> usize {
1027        SCRIPT_CHECKERS
1028            .iter()
1029            .position(|&(s, _)| s == script)
1030            .expect("every Script must appear in SCRIPT_CHECKERS")
1031    }
1032
1033    #[test]
1034    fn script_checkers_table_covers_every_script_exactly_once() {
1035        assert_eq!(SCRIPT_CHECKERS.len(), ALL_SCRIPTS.len());
1036        let mut seen = [0usize; 24];
1037        for (script, _) in SCRIPT_CHECKERS {
1038            seen[script_index(script)] += 1;
1039        }
1040        for (i, count) in seen.iter().enumerate() {
1041            assert_eq!(*count, 1, "{:?} appears {count} times in SCRIPT_CHECKERS", ALL_SCRIPTS[i]);
1042        }
1043    }
1044
1045    #[test]
1046    fn every_script_is_reachable_from_some_bmp_char() {
1047        // Guards against a checker being fully shadowed by an earlier, broader one:
1048        // if some script can never be produced, the table order has swallowed it.
1049        let mut reachable = [false; 24];
1050        for ch in bmp_chars() {
1051            if let Some(script) = detect_char_script(ch) {
1052                reachable[script_index(script)] = true;
1053            }
1054        }
1055        for (i, ok) in reachable.iter().enumerate() {
1056            assert!(*ok, "{:?} is unreachable — shadowed by an earlier checker", ALL_SCRIPTS[i]);
1057        }
1058    }
1059
1060    #[test]
1061    fn overlapping_ranges_resolve_to_the_first_checker_in_the_table() {
1062        // U+1D2B (CYRILLIC LETTER SMALL CAPITAL EL) and U+1D78 (MODIFIER LETTER
1063        // CYRILLIC EN) are listed by *both* is_latin (via U+1D00..=U+1D7F) and
1064        // is_cyrillic. Latin is checked first, so Latin wins. Pinned here because a
1065        // reordering of SCRIPT_CHECKERS would silently flip these to Cyrillic.
1066        for ch in ['\u{1D2B}', '\u{1D78}'] {
1067            assert!(is_latin(ch), "{ch:?} in is_latin's U+1D00..=U+1D7F range");
1068            assert!(is_cyrillic(ch), "{ch:?} is explicitly listed by is_cyrillic");
1069            assert_eq!(detect_char_script(ch), Some(Script::Latin));
1070            assert_eq!(detect_script(&ch.to_string()), Some(Script::Latin));
1071        }
1072    }
1073
1074    // ---------------------------------------------------------------------
1075    // is_* predicates: exact range boundaries
1076    // ---------------------------------------------------------------------
1077
1078    /// Assert a predicate accepts both ends and the midpoint of `[lo, hi]`. The chars
1079    /// bracketing the range are checked separately with `assert_rejects`, because a
1080    /// neighbour may legitimately belong to another range of the *same* predicate.
1081    fn assert_range(name: &str, f: fn(char) -> bool, lo: u32, hi: u32) {
1082        for cp in [lo, hi] {
1083            let ch = char::from_u32(cp).unwrap_or_else(|| panic!("{name}: U+{cp:04X} not a char"));
1084            assert!(f(ch), "{name} should accept its boundary U+{cp:04X}");
1085        }
1086        let mid = char::from_u32(lo + (hi - lo) / 2).unwrap();
1087        assert!(f(mid), "{name} should accept its midpoint {mid:?}");
1088    }
1089
1090    fn assert_rejects(name: &str, f: fn(char) -> bool, cps: &[u32]) {
1091        for &cp in cps {
1092            let Some(ch) = char::from_u32(cp) else { continue };
1093            assert!(!f(ch), "{name} should reject U+{cp:04X}");
1094        }
1095    }
1096
1097    #[test]
1098    fn single_range_predicates_have_exact_boundaries() {
1099        // (name, fn, start, end): each of these is a single contiguous block, so the
1100        // chars immediately before/after must be rejected.
1101        let cases: [(&str, fn(char) -> bool, u32, u32); 12] = [
1102            ("is_hebrew", is_hebrew, 0x0590, 0x05FF),
1103            ("is_georgian", is_georgian, 0x10A0, 0x10FF),
1104            ("is_bengali", is_bengali, 0x0980, 0x09FF),
1105            ("is_hiragana", is_hiragana, 0x3040, 0x309F),
1106            ("is_greek", is_greek, 0x0370, 0x03FF),
1107            ("is_kannada", is_kannada, 0x0C80, 0x0CFF),
1108            ("is_tamil", is_tamil, 0x0B80, 0x0BFF),
1109            ("is_thai", is_thai, 0x0E00, 0x0E7F),
1110            ("is_gujarati", is_gujarati, 0x0A80, 0x0AFF),
1111            ("is_gurmukhi", is_gurmukhi, 0x0A00, 0x0A7F),
1112            ("is_telugu", is_telugu, 0x0C00, 0x0C7F),
1113            ("is_malayalam", is_malayalam, 0x0D00, 0x0D7F),
1114        ];
1115        for (name, f, lo, hi) in cases {
1116            assert_range(name, f, lo, hi);
1117            assert_rejects(name, f, &[lo - 1, hi + 1, 0x0000, 0x0041, 0x10_FFFF]);
1118        }
1119        // The two remaining single-range predicates, spelled out (0x0B00-1 etc. all
1120        // land in neighbouring script blocks, which is exactly what we want to check).
1121        assert_range("is_oriya", is_oriya, 0x0B00, 0x0B7F);
1122        assert_rejects("is_oriya", is_oriya, &[0x0AFF, 0x0B80]);
1123        assert_range("is_myanmar", is_myanmar, 0x1000, 0x109F);
1124        assert_rejects("is_myanmar", is_myanmar, &[0x0FFF, 0x10A0]);
1125        assert_range("is_sinhala", is_sinhala, 0x0D80, 0x0DFF);
1126        assert_rejects("is_sinhala", is_sinhala, &[0x0D7F, 0x0E00]);
1127    }
1128
1129    #[test]
1130    fn is_latin_boundaries() {
1131        assert!(is_latin('a') && is_latin('z') && is_latin('A') && is_latin('Z'));
1132        // The chars bracketing the ASCII letter ranges are all stop chars.
1133        assert_rejects("is_latin", is_latin, &[0x0040, 0x005B, 0x0060, 0x007B, 0x007F]);
1134        assert_range("is_latin", is_latin, 0x0080, 0x024F); // Latin-1 Sup .. Latin Ext-B
1135        assert_range("is_latin", is_latin, 0x0250, 0x02AF); // IPA extensions
1136        assert_rejects("is_latin", is_latin, &[0x02B0, 0x0300, 0x0400, 0x1CFF]);
1137        assert_range("is_latin", is_latin, 0x1D00, 0x1DBF);
1138        assert_rejects("is_latin", is_latin, &[0x1DC0]);
1139        assert_range("is_latin", is_latin, 0x1E00, 0x1EFF);
1140        assert_rejects("is_latin", is_latin, &[0x1DFF, 0x1F00]);
1141        assert_range("is_latin", is_latin, 0x2100, 0x214F);
1142        assert_rejects("is_latin", is_latin, &[0x20FF, 0x2150]);
1143        assert_range("is_latin", is_latin, 0x2C60, 0x2C7F);
1144        assert_rejects("is_latin", is_latin, &[0x2C5F, 0x2C80]);
1145        assert_range("is_latin", is_latin, 0xA720, 0xA7FF);
1146        assert_rejects("is_latin", is_latin, &[0xA71F, 0xA800]);
1147        assert_range("is_latin", is_latin, 0xAB30, 0xAB6F);
1148        assert_rejects("is_latin", is_latin, &[0xAB2F, 0xAB70]);
1149    }
1150
1151    #[test]
1152    fn is_latin_swallows_latin1_symbols_and_letterlike_forms() {
1153        // Pinned quirk, not an endorsement: is_latin's U+0080..=U+00FF and
1154        // U+2100..=U+214F ranges are whole *blocks*, so NBSP, ©, ×, ÷, ™ and ℃ all
1155        // report as Latin and score for Latin in detect_script.
1156        for ch in ['\u{00A0}', '\u{00A9}', '\u{00D7}', '\u{00F7}', '\u{2122}', '\u{2103}'] {
1157            assert!(is_latin(ch), "U+{:04X} is inside is_latin's block ranges", ch as u32);
1158            assert_eq!(detect_script(&ch.to_string()), Some(Script::Latin));
1159        }
1160    }
1161
1162    #[test]
1163    fn is_cyrillic_boundaries_including_the_titlo_gap() {
1164        assert_range("is_cyrillic", is_cyrillic, 0x0400, 0x0484);
1165        // U+0485/U+0486 (combining Cyrillic titlo) are deliberately excluded.
1166        assert_rejects("is_cyrillic", is_cyrillic, &[0x03FF, 0x0485, 0x0486, 0x0530]);
1167        assert_range("is_cyrillic", is_cyrillic, 0x0487, 0x052F);
1168        assert_range("is_cyrillic", is_cyrillic, 0x2DE0, 0x2DFF);
1169        assert_rejects("is_cyrillic", is_cyrillic, &[0x2DDF, 0x2E00]);
1170        assert_range("is_cyrillic", is_cyrillic, 0xA640, 0xA69D);
1171        assert!(is_cyrillic('\u{A69F}'));
1172        assert_rejects("is_cyrillic", is_cyrillic, &[0xA63F, 0xA69E, 0xA6A0]);
1173        assert!(is_cyrillic('\u{1D2B}') && is_cyrillic('\u{1D78}'));
1174    }
1175
1176    #[test]
1177    fn is_arabic_boundaries_and_the_bom() {
1178        assert_range("is_arabic", is_arabic, 0x0600, 0x06FF);
1179        assert_rejects("is_arabic", is_arabic, &[0x05FF, 0x0700, 0x074F, 0x0800, 0x089F]);
1180        assert_range("is_arabic", is_arabic, 0x0750, 0x07FF);
1181        assert_range("is_arabic", is_arabic, 0x08A0, 0x08FF);
1182        assert_range("is_arabic", is_arabic, 0xFB50, 0xFDFF);
1183        assert_range("is_arabic", is_arabic, 0xFE70, 0xFEFF);
1184        assert_rejects("is_arabic", is_arabic, &[0xFB4F, 0xFE00, 0xFE6F, 0xFF00]);
1185        assert_range("is_arabic", is_arabic, 0x1_0E60, 0x1_0E7F);
1186        assert_range("is_arabic", is_arabic, 0x1_EE00, 0x1_EEFF);
1187        assert_rejects("is_arabic", is_arabic, &[0x1_0E5F, 0x1_0E80, 0x1_EDFF, 0x1_EF00]);
1188
1189        // BUG PIN: U+FEFF is the byte-order mark / ZERO WIDTH NO-BREAK SPACE, whose
1190        // Unicode script is Common — but it sits at the top of the Arabic
1191        // Presentation Forms-B block, so is_arabic claims it. A BOM-prefixed text is
1192        // therefore scored as containing one Arabic char. Behaviour pinned as-is;
1193        // see the report.
1194        assert!(is_arabic('\u{FEFF}'));
1195        assert_eq!(detect_script("\u{FEFF}"), Some(Script::Arabic));
1196        // The BOM is not enough to beat a real majority, at least.
1197        assert_eq!(detect_script("\u{FEFF}hello"), Some(Script::Latin));
1198    }
1199
1200    #[test]
1201    fn is_devanagari_boundaries() {
1202        assert_range("is_devanagari", is_devanagari, 0x0900, 0x097F);
1203        assert_range("is_devanagari", is_devanagari, 0xA8E0, 0xA8FF);
1204        assert_range("is_devanagari", is_devanagari, 0x1CD0, 0x1CFF); // Vedic extensions
1205        assert_rejects(
1206            "is_devanagari",
1207            is_devanagari,
1208            &[0x08FF, 0x0980, 0xA8DF, 0xA900, 0x1CCF, 0x1D00],
1209        );
1210    }
1211
1212    #[test]
1213    fn is_ethiopic_boundaries() {
1214        assert_range("is_ethiopic", is_ethiopic, 0x1200, 0x139F);
1215        assert_range("is_ethiopic", is_ethiopic, 0x2D80, 0x2DDF);
1216        assert_range("is_ethiopic", is_ethiopic, 0xAB00, 0xAB2F);
1217        assert_rejects("is_ethiopic", is_ethiopic, &[0x11FF, 0x13A0, 0x2D7F, 0xAAFF]);
1218        // U+2DE0 is where Cyrillic Extended-A starts — must NOT be Ethiopic.
1219        assert!(!is_ethiopic('\u{2DE0}'));
1220        assert!(is_cyrillic('\u{2DE0}'));
1221        // U+AB30 is where is_latin's Latin Extended-E range starts.
1222        assert!(!is_ethiopic('\u{AB30}'));
1223        assert!(is_latin('\u{AB30}'));
1224    }
1225
1226    #[test]
1227    fn is_mandarin_boundaries_and_gaps() {
1228        assert_range("is_mandarin", is_mandarin, 0x2E80, 0x2E99);
1229        assert!(!is_mandarin('\u{2E9A}')); // documented hole in the CJK Radicals block
1230        assert_range("is_mandarin", is_mandarin, 0x2E9B, 0x2EF3);
1231        assert_range("is_mandarin", is_mandarin, 0x2F00, 0x2FD5);
1232        assert!(is_mandarin('\u{3005}') && is_mandarin('\u{3007}'));
1233        assert!(!is_mandarin('\u{3006}')); // U+3006 IDEOGRAPHIC CLOSING MARK is excluded
1234        assert_range("is_mandarin", is_mandarin, 0x3021, 0x3029);
1235        assert_range("is_mandarin", is_mandarin, 0x3038, 0x303B);
1236        assert_range("is_mandarin", is_mandarin, 0x3400, 0x4DB5);
1237        assert_range("is_mandarin", is_mandarin, 0x4E00, 0x9FCC);
1238        assert_range("is_mandarin", is_mandarin, 0xF900, 0xFA6D);
1239        assert_range("is_mandarin", is_mandarin, 0xFA70, 0xFAD9);
1240        assert_rejects(
1241            "is_mandarin",
1242            is_mandarin,
1243            &[0x2E7F, 0x2EF4, 0x2FD6, 0x3004, 0x4DB6, 0x9FCD, 0xF8FF, 0xFA6E, 0xFADA],
1244        );
1245    }
1246
1247    #[test]
1248    fn is_katakana_and_is_hangul_do_not_overlap_in_halfwidth_forms() {
1249        assert_range("is_katakana", is_katakana, 0x30A0, 0x30FF);
1250        assert_range("is_katakana", is_katakana, 0xFF66, 0xFF9F);
1251        assert_rejects("is_katakana", is_katakana, &[0x309F, 0x3100, 0xFF65, 0xFFA0]);
1252
1253        assert_range("is_hangul", is_hangul, 0xAC00, 0xD7AF);
1254        assert_range("is_hangul", is_hangul, 0x1100, 0x11FF);
1255        assert_range("is_hangul", is_hangul, 0x3130, 0x318F);
1256        assert_range("is_hangul", is_hangul, 0xA960, 0xA97F);
1257        assert_range("is_hangul", is_hangul, 0xD7B0, 0xD7FF);
1258        assert_range("is_hangul", is_hangul, 0xFFA0, 0xFFDC);
1259        assert_rejects(
1260            "is_hangul",
1261            is_hangul,
1262            &[0x10FF, 0x1200, 0x312F, 0x3190, 0x3200, 0xABFF, 0xFF66, 0xFF9F, 0xFFDD, 0xFFE0],
1263        );
1264
1265        // The two halfwidth ranges must stay disjoint.
1266        for cp in 0xFF61u32..=0xFFDCu32 {
1267            let ch = char::from_u32(cp).unwrap();
1268            assert!(
1269                !(is_katakana(ch) && is_hangul(ch)),
1270                "U+{cp:04X} claimed by both is_katakana and is_hangul"
1271            );
1272        }
1273    }
1274
1275    #[test]
1276    fn is_khmer_boundaries() {
1277        assert_range("is_khmer", is_khmer, 0x1780, 0x17FF);
1278        assert_range("is_khmer", is_khmer, 0x19E0, 0x19FF);
1279        assert_rejects("is_khmer", is_khmer, &[0x177F, 0x1800, 0x19DF, 0x1A00]);
1280    }
1281
1282    #[test]
1283    fn predicates_reject_the_extremes_and_are_pure() {
1284        for (script, check_fn) in SCRIPT_CHECKERS {
1285            for ch in ['\u{0000}', ' ', '0', '\u{007F}', '\u{10FFFF}'] {
1286                let first = check_fn(ch);
1287                assert_eq!(first, check_fn(ch), "{script:?} checker is not pure for {ch:?}");
1288                assert!(!first, "{script:?} checker claims the non-letter {ch:?}");
1289            }
1290        }
1291    }
1292
1293    // ---------------------------------------------------------------------
1294    // detect_bengali_language
1295    // ---------------------------------------------------------------------
1296
1297    #[test]
1298    fn detect_bengali_language_defaults_to_bengali() {
1299        assert_eq!(detect_bengali_language(""), Language::Bengali);
1300        assert_eq!(detect_bengali_language("   "), Language::Bengali);
1301        assert_eq!(detect_bengali_language("আমার সোনার বাংলা"), Language::Bengali);
1302        // Out-of-script text is not validated — it still falls through to Bengali.
1303        assert_eq!(detect_bengali_language("hello"), Language::Bengali);
1304        assert_eq!(detect_bengali_language("\u{1F600}"), Language::Bengali);
1305    }
1306
1307    #[test]
1308    fn detect_bengali_language_finds_assamese_at_any_position() {
1309        for text in ["\u{09F0}", "\u{09F1}", "\u{09F0}আমার", "আমার\u{09F0}", "আ\u{09F1}র"] {
1310            assert_eq!(detect_bengali_language(text), Language::Assamese, "text {text:?}");
1311        }
1312        // Boundary: the code points either side of the ৰ/ৱ pair are plain Bengali.
1313        assert_eq!(detect_bengali_language("\u{09EF}"), Language::Bengali);
1314        assert_eq!(detect_bengali_language("\u{09F2}"), Language::Bengali);
1315    }
1316
1317    #[test]
1318    fn detect_bengali_language_long_input_terminates() {
1319        let long = "আ".repeat(200_000);
1320        assert_eq!(detect_bengali_language(&long), Language::Bengali);
1321        // Assamese marker at the very end — worst case for the early-return scan.
1322        let mut with_marker = long.clone();
1323        with_marker.push('\u{09F0}');
1324        assert_eq!(detect_bengali_language(&with_marker), Language::Assamese);
1325    }
1326
1327    // ---------------------------------------------------------------------
1328    // detect_cyrillic_language
1329    // ---------------------------------------------------------------------
1330
1331    #[test]
1332    fn detect_cyrillic_language_defaults_to_russian() {
1333        assert_eq!(detect_cyrillic_language(""), Language::Russian);
1334        assert_eq!(detect_cyrillic_language("Привет мир"), Language::Russian);
1335        assert_eq!(detect_cyrillic_language("hello"), Language::Russian);
1336        assert_eq!(detect_cyrillic_language("\u{1F600}"), Language::Russian);
1337    }
1338
1339    #[test]
1340    fn detect_cyrillic_language_markers() {
1341        let cases: [(&str, Language); 7] = [
1342            ("\u{0460}", Language::SlavonicChurch),
1343            ("ѓ", Language::Macedonian),
1344            ("ў", Language::Belarusian),
1345            ("ї", Language::Ukrainian),
1346            ("ө", Language::Mongolian),
1347            ("ј", Language::SerbianCyrillic),
1348            ("щ", Language::Bulgarian),
1349        ];
1350        for (text, expected) in cases {
1351            assert_eq!(detect_cyrillic_language(text), expected, "text {text:?}");
1352        }
1353        // Old-Cyrillic block boundaries: U+0460..=U+047F inclusive, nothing outside.
1354        assert_eq!(detect_cyrillic_language("\u{047F}"), Language::SlavonicChurch);
1355        assert_eq!(detect_cyrillic_language("\u{0480}"), Language::Russian);
1356        // U+045F sits one below the Old-Cyrillic range — and it is 'џ', so it falls
1357        // through to the Serbian arm rather than to the Russian default.
1358        assert_eq!(detect_cyrillic_language("\u{045F}"), Language::SerbianCyrillic);
1359    }
1360
1361    #[test]
1362    fn detect_cyrillic_language_is_positional_not_priority_ordered() {
1363        // The comments in the function claim Old-Cyrillic is the "highest priority"
1364        // check, but every arm returns immediately, so the *first marker char in the
1365        // text* wins regardless of its claimed rank. Pinned; see the report.
1366        assert_eq!(detect_cyrillic_language("щ\u{0460}"), Language::Bulgarian);
1367        assert_eq!(detect_cyrillic_language("\u{0460}щ"), Language::SlavonicChurch);
1368        assert_eq!(detect_cyrillic_language("ўщ"), Language::Belarusian);
1369        assert_eq!(detect_cyrillic_language("щў"), Language::Bulgarian);
1370    }
1371
1372    #[test]
1373    fn detect_cyrillic_language_long_input_terminates() {
1374        let long = "а".repeat(200_000);
1375        assert_eq!(detect_cyrillic_language(&long), Language::Russian);
1376        let mut trailing = long;
1377        trailing.push('\u{0460}');
1378        assert_eq!(detect_cyrillic_language(&trailing), Language::SlavonicChurch);
1379    }
1380
1381    // ---------------------------------------------------------------------
1382    // detect_devanagari_language
1383    // ---------------------------------------------------------------------
1384
1385    #[test]
1386    fn detect_devanagari_language_defaults_to_hindi() {
1387        assert_eq!(detect_devanagari_language(""), Language::Hindi);
1388        assert_eq!(detect_devanagari_language("नमस्ते"), Language::Hindi);
1389        assert_eq!(detect_devanagari_language("hello"), Language::Hindi);
1390    }
1391
1392    #[test]
1393    fn detect_devanagari_language_markers_and_boundaries() {
1394        assert_eq!(detect_devanagari_language("\u{0933}"), Language::Marathi); // ळ
1395        assert_eq!(detect_devanagari_language("\u{1CD0}"), Language::Sanskrit);
1396        assert_eq!(detect_devanagari_language("\u{1CFF}"), Language::Sanskrit);
1397        assert_eq!(detect_devanagari_language("\u{1CCF}"), Language::Hindi);
1398        assert_eq!(detect_devanagari_language("\u{1D00}"), Language::Hindi);
1399        assert_eq!(detect_devanagari_language("\u{0932}"), Language::Hindi);
1400        assert_eq!(detect_devanagari_language("\u{0934}"), Language::Hindi);
1401        // Positional, not priority-ordered — whichever marker comes first wins.
1402        assert_eq!(detect_devanagari_language("\u{1CD0}\u{0933}"), Language::Sanskrit);
1403        assert_eq!(detect_devanagari_language("\u{0933}\u{1CD0}"), Language::Marathi);
1404    }
1405
1406    #[test]
1407    fn detect_devanagari_language_long_input_terminates() {
1408        let long = "न".repeat(200_000);
1409        assert_eq!(detect_devanagari_language(&long), Language::Hindi);
1410    }
1411
1412    // ---------------------------------------------------------------------
1413    // detect_greek_language
1414    // ---------------------------------------------------------------------
1415
1416    #[test]
1417    fn detect_greek_language_defaults_to_monotonic() {
1418        assert_eq!(detect_greek_language(""), Language::GreekMono);
1419        assert_eq!(detect_greek_language("Γειά σου"), Language::GreekMono);
1420        assert_eq!(detect_greek_language("hello"), Language::GreekMono);
1421    }
1422
1423    #[test]
1424    fn detect_greek_language_markers_and_boundaries() {
1425        assert_eq!(detect_greek_language("\u{2C80}"), Language::Coptic);
1426        assert_eq!(detect_greek_language("\u{2CFF}"), Language::Coptic);
1427        assert_eq!(detect_greek_language("\u{2C7F}"), Language::GreekMono);
1428        assert_eq!(detect_greek_language("\u{2D00}"), Language::GreekMono);
1429        assert_eq!(detect_greek_language("\u{1F00}"), Language::GreekPoly);
1430        assert_eq!(detect_greek_language("\u{1FFF}"), Language::GreekPoly);
1431        assert_eq!(detect_greek_language("\u{1EFF}"), Language::GreekMono);
1432        assert_eq!(detect_greek_language("\u{2000}"), Language::GreekMono);
1433        // Positional, not priority-ordered.
1434        assert_eq!(detect_greek_language("\u{1F00}\u{2C80}"), Language::GreekPoly);
1435        assert_eq!(detect_greek_language("\u{2C80}\u{1F00}"), Language::Coptic);
1436    }
1437
1438    #[test]
1439    fn detect_greek_language_long_input_terminates() {
1440        let long = "α".repeat(200_000);
1441        assert_eq!(detect_greek_language(&long), Language::GreekMono);
1442    }
1443
1444    // ---------------------------------------------------------------------
1445    // detect_latin_language
1446    // ---------------------------------------------------------------------
1447
1448    #[test]
1449    fn detect_latin_language_defaults_to_english() {
1450        assert_eq!(detect_latin_language(""), Language::EnglishUS);
1451        assert_eq!(detect_latin_language("the quick brown fox"), Language::EnglishUS);
1452        assert_eq!(detect_latin_language("0123456789 !@#$%"), Language::EnglishUS);
1453        assert_eq!(detect_latin_language("\u{1F600}"), Language::EnglishUS);
1454        // Non-Latin text is not validated — it still falls through to English.
1455        assert_eq!(detect_latin_language("你好"), Language::EnglishUS);
1456    }
1457
1458    #[test]
1459    fn detect_latin_language_single_char_markers() {
1460        let cases: [(char, Language); 17] = [
1461            ('ß', Language::German1996),
1462            ('ä', Language::German1996),
1463            ('ő', Language::Hungarian),
1464            ('ł', Language::Polish),
1465            ('ř', Language::Czech),
1466            ('ľ', Language::Slovak),
1467            ('ā', Language::Latvian),
1468            ('ą', Language::Lithuanian),
1469            ('ă', Language::Romanian),
1470            ('ğ', Language::Turkish),
1471            ('đ', Language::Croatian),
1472            ('þ', Language::Icelandic),
1473            ('ŵ', Language::Welsh),
1474            ('æ', Language::NorwegianBokmal),
1475            ('å', Language::Swedish),
1476            ('ñ', Language::Spanish),
1477            ('á', Language::Spanish),
1478        ];
1479        for (ch, expected) in cases {
1480            assert_eq!(detect_latin_language(&ch.to_string()), expected, "char {ch:?}");
1481            // Position within the text must not matter for early-return markers.
1482            assert_eq!(detect_latin_language(&format!("word {ch} word")), expected);
1483        }
1484    }
1485
1486    #[test]
1487    fn detect_latin_language_flag_combinations() {
1488        // The three deferred flags (ç / õ / ã) drive the French-Estonian-Portuguese
1489        // tie-break at the end of the scan.
1490        assert_eq!(detect_latin_language("ç"), Language::French);
1491        assert_eq!(detect_latin_language("garçon"), Language::French);
1492        assert_eq!(detect_latin_language("õ"), Language::Estonian);
1493        assert_eq!(detect_latin_language("õhtu"), Language::Estonian);
1494        assert_eq!(detect_latin_language("ã"), Language::Portuguese);
1495        assert_eq!(detect_latin_language("çõ"), Language::Portuguese);
1496        assert_eq!(detect_latin_language("çã"), Language::Portuguese);
1497        assert_eq!(detect_latin_language("õã"), Language::Portuguese);
1498        assert_eq!(detect_latin_language("çõã"), Language::Portuguese);
1499        assert_eq!(detect_latin_language("informação"), Language::Portuguese);
1500    }
1501
1502    #[test]
1503    fn detect_latin_language_accented_vowel_short_circuits_the_flags() {
1504        // BUG PIN: 'á'|'é'|'í'|'ó'|'ú' return Spanish *immediately*, so any French or
1505        // Portuguese word carrying an accented vowel before its ç/õ/ã is reported as
1506        // Spanish — the flag tie-break never runs. Pinned as-is; see the report.
1507        assert_eq!(detect_latin_language("café"), Language::Spanish);
1508        assert_eq!(detect_latin_language("présentation"), Language::Spanish);
1509        assert_eq!(detect_latin_language("é ç"), Language::Spanish);
1510        // Order matters: with the ç first, the flag survives to the tie-break — but
1511        // only because no accented vowel is seen at all.
1512        assert_eq!(detect_latin_language("ç e"), Language::French);
1513    }
1514
1515    #[test]
1516    fn detect_latin_language_first_marker_wins() {
1517        assert_eq!(detect_latin_language("ßä"), Language::German1996);
1518        assert_eq!(detect_latin_language("łß"), Language::Polish);
1519        assert_eq!(detect_latin_language("åæ"), Language::Swedish);
1520        assert_eq!(detect_latin_language("æå"), Language::NorwegianBokmal);
1521    }
1522
1523    #[test]
1524    fn detect_latin_language_long_input_terminates() {
1525        let long = "a".repeat(500_000);
1526        assert_eq!(detect_latin_language(&long), Language::EnglishUS);
1527        // Marker at the very end — no early return until the last char.
1528        let mut trailing = long;
1529        trailing.push('ß');
1530        assert_eq!(detect_latin_language(&trailing), Language::German1996);
1531    }
1532
1533    // ---------------------------------------------------------------------
1534    // script_to_language
1535    // ---------------------------------------------------------------------
1536
1537    #[test]
1538    fn script_to_language_is_total_over_every_script() {
1539        // No script/text combination may panic, and the result must be deterministic.
1540        let texts = [
1541            "",
1542            " ",
1543            "hello",
1544            "\u{1F600}",
1545            "\u{0000}\u{FFFF}\u{10FFFF}",
1546            "ß ç õ ã щ ळ \u{09F0} \u{2C80}",
1547        ];
1548        for script in ALL_SCRIPTS {
1549            for text in texts {
1550                let first = script_to_language(script, text);
1551                let second = script_to_language(script, text);
1552                assert_eq!(first, second, "{script:?} + {text:?} is not deterministic");
1553            }
1554        }
1555    }
1556
1557    #[test]
1558    fn script_to_language_direct_mappings_ignore_the_text() {
1559        // These 19 scripts map to a fixed language; the text argument must not matter,
1560        // not even for text stuffed with every other script's marker chars.
1561        let cases: [(Script, Language); 19] = [
1562            (Script::Ethiopic, Language::Ethiopic),
1563            (Script::Georgian, Language::Georgian),
1564            (Script::Gujarati, Language::Gujarati),
1565            (Script::Gurmukhi, Language::Panjabi),
1566            (Script::Kannada, Language::Kannada),
1567            (Script::Malayalam, Language::Malayalam),
1568            (Script::Mandarin, Language::Chinese),
1569            (Script::Oriya, Language::Oriya),
1570            (Script::Tamil, Language::Tamil),
1571            (Script::Telugu, Language::Telugu),
1572            (Script::Thai, Language::Thai),
1573            (Script::Myanmar, Language::Thai),
1574            (Script::Khmer, Language::Thai),
1575            (Script::Sinhala, Language::Hindi),
1576            (Script::Arabic, Language::Chinese),
1577            (Script::Hebrew, Language::Chinese),
1578            (Script::Hangul, Language::Chinese),
1579            (Script::Hiragana, Language::Chinese),
1580            (Script::Katakana, Language::Chinese),
1581        ];
1582        let long = "x".repeat(10_000);
1583        for (script, expected) in cases {
1584            for text in ["", "ß щ ळ \u{09F0} \u{2C80} \u{1F600}", long.as_str()] {
1585                assert_eq!(
1586                    script_to_language(script, text),
1587                    expected,
1588                    "{script:?} must map to {expected:?} regardless of the text"
1589                );
1590            }
1591        }
1592    }
1593
1594    #[test]
1595    fn script_to_language_delegates_the_five_text_sensitive_scripts() {
1596        let probes = ["", "hello", "ß", "щ", "\u{0933}", "\u{2C80}", "\u{09F0}"];
1597        for text in probes {
1598            assert_eq!(
1599                script_to_language(Script::Bengali, text),
1600                detect_bengali_language(text),
1601                "Bengali delegation broke for {text:?}"
1602            );
1603            assert_eq!(
1604                script_to_language(Script::Cyrillic, text),
1605                detect_cyrillic_language(text),
1606                "Cyrillic delegation broke for {text:?}"
1607            );
1608            assert_eq!(
1609                script_to_language(Script::Devanagari, text),
1610                detect_devanagari_language(text),
1611                "Devanagari delegation broke for {text:?}"
1612            );
1613            assert_eq!(
1614                script_to_language(Script::Greek, text),
1615                detect_greek_language(text),
1616                "Greek delegation broke for {text:?}"
1617            );
1618            assert_eq!(
1619                script_to_language(Script::Latin, text),
1620                detect_latin_language(text),
1621                "Latin delegation broke for {text:?}"
1622            );
1623        }
1624    }
1625
1626    #[test]
1627    fn detect_script_then_script_to_language_end_to_end() {
1628        let cases: [(&str, Script, Language); 6] = [
1629            ("straße", Script::Latin, Language::German1996),
1630            ("Привет", Script::Cyrillic, Language::Russian),
1631            ("Здравейте, щастие", Script::Cyrillic, Language::Bulgarian),
1632            ("你好世界", Script::Mandarin, Language::Chinese),
1633            ("สวัสดี", Script::Thai, Language::Thai),
1634            ("ಕನ್ನಡ", Script::Kannada, Language::Kannada),
1635        ];
1636        for (text, script, language) in cases {
1637            let detected = detect_script(text).unwrap_or_else(|| panic!("no script for {text:?}"));
1638            assert_eq!(detected, script, "script for {text:?}");
1639            assert_eq!(script_to_language(detected, text), language, "language for {text:?}");
1640        }
1641    }
1642
1643    #[test]
1644    fn script_to_language_survives_pseudo_random_text() {
1645        for seed in [7u64, 0x1234_5678_9ABC_DEF0] {
1646            let text = lcg_chars(2_000, seed);
1647            for script in ALL_SCRIPTS {
1648                let lang = script_to_language(script, &text);
1649                assert_eq!(lang, script_to_language(script, &text));
1650            }
1651        }
1652    }
1653}