1#[cfg(feature = "text_layout_hyphenation")]
37use hyphenation::Language as HyphenationLanguage;
38#[cfg(feature = "text_layout_hyphenation")]
39pub use hyphenation::Language;
40
41#[cfg(not(feature = "text_layout_hyphenation"))]
44#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
45#[allow(dead_code)]
46pub enum Language {
47 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 Russian,
69 Ukrainian,
70 Belarusian,
71 Bulgarian,
72 Macedonian,
73 SerbianCyrillic,
74 Mongolian,
75 SlavonicChurch,
76 GreekMono,
78 GreekPoly,
79 Coptic,
80 Hindi,
82 Bengali,
83 Assamese,
84 Marathi,
85 Sanskrit,
86 Gujarati,
87 Panjabi,
88 Kannada,
89 Malayalam,
90 Oriya,
91 Tamil,
92 Telugu,
93 Georgian,
95 Ethiopic,
96 Thai,
97 Chinese,
98}
99
100#[derive(PartialEq, Eq, Debug, Clone, Copy)]
101pub enum Script {
102 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#[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
167pub 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 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 if found {
199 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
231fn detect_bengali_language(text: &str) -> Language {
233 for c in text.chars() {
234 if matches!(c, '\u{09F0}' | '\u{09F1}') {
237 return Language::Assamese;
239 }
240 }
241 Language::Bengali
243}
244
245fn detect_cyrillic_language(text: &str) -> Language {
246 for c in text.chars() {
247 match c {
248 '\u{0460}'..='\u{047F}' => return Language::SlavonicChurch,
250 'ѓ' | 'ќ' | 'ѕ' => return Language::Macedonian,
253 'ў' => return Language::Belarusian,
254 'є' | 'і' | 'ї' | 'ґ' => return Language::Ukrainian,
255 'ө' | 'ү' | 'һ' => return Language::Mongolian,
256 'ј' | 'љ' | 'њ' | 'ћ' | 'ђ' | 'џ' => return Language::SerbianCyrillic,
257 'щ' => 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 '\u{0933}' => return Language::Marathi, '\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 '\u{2C80}'..='\u{2CFF}' => return Language::Coptic,
286 '\u{1F00}'..='\u{1FFF}' => return Language::GreekPoly,
288 _ => {}
289 }
290 }
291
292 Language::GreekMono
293}
294
295#[allow(clippy::match_same_arms)] fn detect_latin_language(text: &str) -> Language {
297 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 'ß' => 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, 'þ' | 'ð' => return Language::Icelandic,
319 'ŵ' | 'ŷ' => return Language::Welsh,
320 'æ' | 'ø' => return Language::NorwegianBokmal, 'å' => return Language::Swedish, 'ñ' => return Language::Spanish,
323 'ä' | 'ö' | 'ü' => return Language::German1996,
324
325 'õ' => has_portuguese_o = true,
328 'ã' => has_portuguese_a = true,
329
330 'ç' => has_french_c = true, 'á' | 'é' | 'í' | 'ó' | 'ú' => return Language::Spanish,
333
334 _ => (),
335 }
336 }
337
338 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)] #[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 Script::Myanmar => Language::Thai,
377 Script::Khmer => Language::Thai,
378 Script::Sinhala => Language::Hindi,
379
380 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#[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#[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#[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#[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#[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 | '\u{FF66}'..='\u{FF9F}'
482 )
483}
484
485#[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 | '\u{FFA0}'..='\u{FFDC}'
499 )
500}
501
502#[must_use] pub const fn is_greek(ch: char) -> bool {
504 matches!(ch, '\u{0370}'..='\u{03FF}')
505}
506
507#[must_use] pub const fn is_kannada(ch: char) -> bool {
509 matches!(ch, '\u{0C80}'..='\u{0CFF}')
510}
511
512#[must_use] pub const fn is_tamil(ch: char) -> bool {
514 matches!(ch, '\u{0B80}'..='\u{0BFF}')
515}
516
517#[must_use] pub const fn is_thai(ch: char) -> bool {
519 matches!(ch, '\u{0E00}'..='\u{0E7F}')
520}
521
522#[must_use] pub const fn is_gujarati(ch: char) -> bool {
524 matches!(ch, '\u{0A80}'..='\u{0AFF}')
525}
526
527#[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#[must_use] pub const fn is_malayalam(ch: char) -> bool {
539 matches!(ch, '\u{0D00}'..='\u{0D7F}')
540}
541
542#[must_use] pub const fn is_oriya(ch: char) -> bool {
544 matches!(ch, '\u{0B00}'..='\u{0B7F}')
545}
546
547#[must_use] pub const fn is_myanmar(ch: char) -> bool {
549 matches!(ch, '\u{1000}'..='\u{109F}')
550}
551
552#[must_use] pub const fn is_sinhala(ch: char) -> bool {
554 matches!(ch, '\u{0D80}'..='\u{0DFF}')
555}
556
557#[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 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 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}')); 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 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 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 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 fn bmp_chars() -> impl Iterator<Item = char> {
648 (0u32..=0xFFFF).filter_map(char::from_u32)
649 }
650
651 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 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 assert!(is_stop_char('\u{0040}'));
696 assert!(!is_stop_char('\u{0041}')); assert!(!is_stop_char('\u{005A}')); assert!(is_stop_char('\u{005B}'));
700 assert!(is_stop_char('\u{0060}'));
701 assert!(!is_stop_char('\u{0061}')); assert!(!is_stop_char('\u{007A}')); assert!(is_stop_char('\u{007B}'));
705 assert!(is_stop_char('\u{007E}'));
706 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 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 #[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 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}", ] {
762 assert_eq!(detect_script(text), None, "garbage {text:?}");
763 }
764 }
765
766 #[test]
767 fn detect_script_boundary_number_strings() {
768 for text in [
770 "0",
771 "-0",
772 "9223372036854775807", "-9223372036854775808", "18446744073709551615", "1e309", "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 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 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}", "\u{1F600}\u{1F468}\u{200D}\u{1F469}", "e\u{0301}", "\u{0301}\u{0302}\u{0303}", "\u{10FFFF}", "\u{FFFF}", ] {
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 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 let long_latin = "a".repeat(1_000_000);
855 assert_eq!(detect_script(&long_latin), Some(Script::Latin));
856
857 let long_junk = "\u{1F600}".repeat(200_000);
860 assert_eq!(detect_script(&long_junk), None);
861
862 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 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 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 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 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 #[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); assert_eq!(detect_char_script(char::MAX), None);
967 assert_eq!(detect_char_script('\u{FFFF}'), None); assert_eq!(detect_char_script('\u{E000}'), None); assert_eq!(detect_char_script('a'), Some(Script::Latin));
970 assert_eq!(detect_char_script('\u{1EE00}'), Some(Script::Arabic)); assert_eq!(detect_char_script('\u{10E60}'), Some(Script::Arabic)); }
973
974 #[test]
975 fn detect_char_script_astral_planes_agree_with_the_table() {
976 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 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 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 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 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 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 assert_rejects("is_latin", is_latin, &[0x0040, 0x005B, 0x0060, 0x007B, 0x007F]);
1134 assert_range("is_latin", is_latin, 0x0080, 0x024F); assert_range("is_latin", is_latin, 0x0250, 0x02AF); 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 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 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 assert!(is_arabic('\u{FEFF}'));
1195 assert_eq!(detect_script("\u{FEFF}"), Some(Script::Arabic));
1196 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); 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 assert!(!is_ethiopic('\u{2DE0}'));
1220 assert!(is_cyrillic('\u{2DE0}'));
1221 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}')); 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}')); 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 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 #[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 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 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 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 #[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 assert_eq!(detect_cyrillic_language("\u{047F}"), Language::SlavonicChurch);
1355 assert_eq!(detect_cyrillic_language("\u{0480}"), Language::Russian);
1356 assert_eq!(detect_cyrillic_language("\u{045F}"), Language::SerbianCyrillic);
1359 }
1360
1361 #[test]
1362 fn detect_cyrillic_language_is_positional_not_priority_ordered() {
1363 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 #[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); 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 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 #[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 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 #[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 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 assert_eq!(detect_latin_language(&format!("word {ch} word")), expected);
1483 }
1484 }
1485
1486 #[test]
1487 fn detect_latin_language_flag_combinations() {
1488 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 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 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 let mut trailing = long;
1529 trailing.push('ß');
1530 assert_eq!(detect_latin_language(&trailing), Language::German1996);
1531 }
1532
1533 #[test]
1538 fn script_to_language_is_total_over_every_script() {
1539 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 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}