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")]
37
use hyphenation::Language as HyphenationLanguage;
38
#[cfg(feature = "text_layout_hyphenation")]
39
pub 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)]
46
pub 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)]
101
pub 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
2072081
#[must_use] pub const fn is_stop_char(ch: char) -> bool {
134
2072081
    matches!(ch, '\u{0000}'..='\u{0040}' | '\u{005B}'..='\u{0060}' | '\u{007B}'..='\u{007E}')
135
2072081
}
136

            
137
type ScriptChecker = (Script, fn(char) -> bool);
138
type ScriptCounter = (Script, fn(char) -> bool, usize);
139

            
140
const 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).
171
121025
pub fn detect_script(text: &str) -> Option<Script> {
172
2904600
    let mut script_counters: [ScriptCounter; 24] = SCRIPT_CHECKERS.map(|(s, f)| (s, f, 0));
173

            
174
121025
    let half = text.chars().count() / 2;
175

            
176
2008554
    for ch in text.chars() {
177
2008554
        if is_stop_char(ch) {
178
329438
            continue;
179
1679116
        }
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
8039626
        for i in 0..script_counters.len() {
184
7939667
            let found = {
185
8039626
                let (script, check_fn, ref mut count) = script_counters[i];
186
8039626
                if check_fn(ch) {
187
1430300
                    *count += 1;
188
1430300
                    if *count > half {
189
99959
                        return Some(script);
190
1330341
                    }
191
1330341
                    true
192
                } else {
193
6609326
                    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
7939667
            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
1330341
                if i > 0 {
203
401256
                    script_counters.swap(i - 1, i);
204
929085
                }
205
1330341
                break;
206
6609326
            }
207
        }
208
    }
209

            
210
21066
    let (script, _, count) = script_counters
211
21066
        .iter()
212
21066
        .copied()
213
21066
        .max_by_key(|&(_, _, count)| count)
214
21066
        .unwrap();
215
21066
    if count != 0 {
216
1084
        Some(script)
217
    } else {
218
19982
        None
219
    }
220
121025
}
221

            
222
254811
#[must_use] pub fn detect_char_script(ch: char) -> Option<Script> {
223
2846707
    for &(script, check_fn) in &SCRIPT_CHECKERS {
224
2777257
        if check_fn(ch) {
225
185361
            return Some(script);
226
2591896
        }
227
    }
228
69450
    None
229
254811
}
230

            
231
/// Iterates through the text once and returns as soon as an Assamese-specific character is found.
232
44
fn detect_bengali_language(text: &str) -> Language {
233
408104
    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
408104
        if matches!(c, '\u{09F0}' | '\u{09F1}') {
237
            // ৰ, ৱ
238
10
            return Language::Assamese;
239
408094
        }
240
    }
241
    // If we finish the loop without finding any Assamese characters, it's Bengali.
242
34
    Language::Bengali
243
44
}
244

            
245
52
fn detect_cyrillic_language(text: &str) -> Language {
246
408107
    for c in text.chars() {
247
408107
        match c {
248
            // Highest priority: Old Cyrillic characters for Slavonic Church. Return immediately.
249
8003
            '\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
1
            'ѓ' | 'ќ' | 'ѕ' => return Language::Macedonian,
253
2
            'ў' => return Language::Belarusian,
254
1
            'є' | 'і' | 'ї' | 'ґ' => return Language::Ukrainian,
255
1
            'ө' | 'ү' | 'һ' => return Language::Mongolian,
256
2
            'ј' | 'љ' | 'њ' | 'ћ' | 'ђ' | 'џ' => 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
8
            'щ' => return Language::Bulgarian,
260
408088
            _ => {}
261
        }
262
    }
263

            
264
33
    Language::Russian
265
52
}
266

            
267
43
fn detect_devanagari_language(text: &str) -> Language {
268
208082
    for c in text.chars() {
269
208082
        match c {
270
            // Marathi has higher priority in the original logic. Return immediately.
271
6
            '\u{0933}' => return Language::Marathi, // ळ
272
            // Flag for Sanskrit Vedic extensions.
273
7964
            '\u{1CD0}'..='\u{1CFF}' => return Language::Sanskrit,
274
208073
            _ => (),
275
        }
276
    }
277

            
278
34
    Language::Hindi
279
43
}
280

            
281
53
fn detect_greek_language(text: &str) -> Language {
282
204543
    for c in text.chars() {
283
204543
        match c {
284
            // Coptic has higher priority. Return immediately.
285
4400
            '\u{2C80}'..='\u{2CFF}' => return Language::Coptic,
286
            // Flag for Greek Extended (Polytonic) characters.
287
4414
            '\u{1F00}'..='\u{1FFF}' => return Language::GreekPoly,
288
204531
            _ => {}
289
        }
290
    }
291

            
292
41
    Language::GreekMono
293
53
}
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)
296
49585
fn detect_latin_language(text: &str) -> Language {
297
    // Flags for languages checked near the end of the original if-else chain.
298
49585
    let mut has_french_c = false;
299
49585
    let mut has_portuguese_o = false;
300
49585
    let mut has_portuguese_a = false;
301

            
302
1844961
    for c in text.chars() {
303
1844961
        match c {
304
            // --- Early Return Cases (in order of priority) ---
305
9
            'ß' => return Language::German1996,
306
2
            'ő' | 'ű' => return Language::Hungarian,
307
3
            'ł' => return Language::Polish,
308
2
            'ř' | 'ů' => return Language::Czech,
309
2
            'ľ' | 'ĺ' | 'ŕ' => return Language::Slovak,
310
            'ā' | 'ē' | 'ģ' | 'ī' | 'ķ' | 'ļ' | 'ņ' | 'ō' | 'ū' => {
311
2
                return Language::Latvian
312
            }
313
2
            'ą' | 'ę' | 'ė' | 'į' | 'ų' => return Language::Lithuanian,
314
2
            'ă' | 'ș' | 'ț' => return Language::Romanian,
315
2
            'ğ' | 'ı' | 'ş' => return Language::Turkish,
316
2
            'đ' => return Language::Croatian, /* Also used in Vietnamese, but Croatian is the */
317
            // original's intent
318
2
            'þ' | 'ð' => return Language::Icelandic,
319
2
            'ŵ' | 'ŷ' => return Language::Welsh,
320
3
            'æ' | 'ø' => return Language::NorwegianBokmal, // And Danish
321
3
            'å' => return Language::Swedish,               // And Norwegian, Finnish
322
2
            'ñ' => return Language::Spanish,
323
11
            'ä' | 'ö' | 'ü' => return Language::German1996,
324

            
325
            // NOTE: 'õ' is used by both Estonian and Portuguese
326
            // Since Estonian is checked first, it takes precedence.
327
5
            'õ' => has_portuguese_o = true,
328
5
            'ã' => has_portuguese_a = true,
329

            
330
            // --- Flag-setting Cases ---
331
7
            'ç' => has_french_c = true, // Also in Portuguese
332
5
            'á' | 'é' | 'í' | 'ó' | 'ú' => return Language::Spanish,
333

            
334
1844888
            _ => (),
335
        }
336
    }
337

            
338
    // decide between portuguese, estonian and french
339

            
340
49529
    if has_french_c && !has_portuguese_o && !has_portuguese_a {
341
3
        return Language::French;
342
49526
    }
343

            
344
49526
    if has_portuguese_o && !has_french_c && !has_portuguese_a {
345
2
        return Language::Estonian;
346
49524
    }
347

            
348
49524
    if has_portuguese_o || has_portuguese_a || has_french_c {
349
6
        return Language::Portuguese;
350
49518
    }
351

            
352
49518
    Language::EnglishUS
353
49585
}
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
50346
#[must_use] pub fn script_to_language(script: Script, text: &str) -> Language {
357
50346
    match script {
358
19
        Script::Ethiopic => Language::Ethiopic,
359
19
        Script::Georgian => Language::Georgian,
360
19
        Script::Gujarati => Language::Gujarati,
361
19
        Script::Gurmukhi => Language::Panjabi,
362
20
        Script::Kannada => Language::Kannada,
363
19
        Script::Malayalam => Language::Malayalam,
364
101
        Script::Mandarin => Language::Chinese,
365
19
        Script::Oriya => Language::Oriya,
366
19
        Script::Tamil => Language::Tamil,
367
19
        Script::Telugu => Language::Telugu,
368
20
        Script::Thai => Language::Thai,
369
23
        Script::Bengali => detect_bengali_language(text),
370
25
        Script::Cyrillic => detect_cyrillic_language(text),
371
23
        Script::Devanagari => detect_devanagari_language(text),
372
32
        Script::Greek => detect_greek_language(text),
373
49519
        Script::Latin => detect_latin_language(text),
374

            
375
        // not directly matchable
376
19
        Script::Myanmar => Language::Thai,
377
19
        Script::Khmer => Language::Thai,
378
19
        Script::Sinhala => Language::Hindi,
379

            
380
        // no classical hyphenation behaviour
381
64
        Script::Arabic => Language::Chinese,
382
253
        Script::Hebrew => Language::Chinese,
383
19
        Script::Hangul => Language::Chinese,
384
19
        Script::Hiragana => Language::Chinese,
385
19
        Script::Katakana => Language::Chinese,
386
    }
387
50346
}
388

            
389
1025267
#[must_use] pub const fn is_cyrillic(ch: char) -> bool {
390
1025267
    matches!(ch,
391
822386
        '\u{0400}'..='\u{0484}'
392
621393
        | '\u{0487}'..='\u{052F}'
393
552144
        | '\u{2DE0}'..='\u{2DFF}'
394
364260
        | '\u{A640}'..='\u{A69D}'
395
        | '\u{1D2B}'
396
        | '\u{1D78}'
397
        | '\u{A69F}'
398
    )
399
1025267
}
400

            
401
// https://en.wikipedia.org/wiki/Latin_script_in_Unicode
402
2016602
#[must_use] pub const fn is_latin(ch: char) -> bool {
403
2016602
    matches!(ch,
404
1928109
        'a'..='z'
405
922580
        | 'A'..='Z'
406
834507
        | '\u{0080}'..='\u{00FF}'
407
833393
        | '\u{0100}'..='\u{017F}'
408
832496
        | '\u{0180}'..='\u{024F}'
409
831033
        | '\u{0250}'..='\u{02AF}'
410
585800
        | '\u{1D00}'..='\u{1D7F}'
411
584894
        | '\u{1D80}'..='\u{1DBF}'
412
583991
        | '\u{1E00}'..='\u{1EFF}'
413
577398
        | '\u{2100}'..='\u{214F}'
414
556714
        | '\u{2C60}'..='\u{2C7F}'
415
364746
        | '\u{A720}'..='\u{A7FF}'
416
357543
        | '\u{AB30}'..='\u{AB6F}'
417
    )
418
2016602
}
419

            
420
// Based on https://en.wikipedia.org/wiki/Arabic_script_in_Unicode
421
623667
#[must_use] pub const fn is_arabic(ch: char) -> bool {
422
623667
    matches!(ch,
423
618859
        '\u{0600}'..='\u{06FF}'
424
616284
        | '\u{0750}'..='\u{07FF}'
425
613915
        | '\u{08A0}'..='\u{08FF}'
426
240658
        | '\u{FB50}'..='\u{FDFF}'
427
235031
        | '\u{FE70}'..='\u{FEFF}'
428
230977
        | '\u{10E60}'..='\u{10E7F}'
429
227675
        | '\u{1EE00}'..='\u{1EEFF}'
430
    )
431
623667
}
432

            
433
// Based on https://en.wikipedia.org/wiki/Devanagari#Unicode
434
442417
#[must_use] pub const fn is_devanagari(ch: char) -> bool {
435
442417
    matches!(ch, '\u{0900}'..='\u{097F}' | '\u{A8E0}'..='\u{A8FF}' | '\u{1CD0}'..='\u{1CFF}')
436
442417
}
437

            
438
// Based on https://www.key-shortcut.com/en/writing-systems/ethiopian-script/
439
440574
#[must_use] pub const fn is_ethiopic(ch: char) -> bool {
440
440574
    matches!(ch, '\u{1200}'..='\u{139F}' | '\u{2D80}'..='\u{2DDF}' | '\u{AB00}'..='\u{AB2F}')
441
440574
}
442

            
443
// Based on https://en.wikipedia.org/wiki/Hebrew_(Unicode_block)
444
441365
#[must_use] pub const fn is_hebrew(ch: char) -> bool {
445
441365
    matches!(ch, '\u{0590}'..='\u{05FF}')
446
441365
}
447

            
448
436715
#[must_use] pub const fn is_georgian(ch: char) -> bool {
449
436715
    matches!(ch, '\u{10A0}'..='\u{10FF}')
450
436715
}
451

            
452
642132
#[must_use] pub const fn is_mandarin(ch: char) -> bool {
453
642132
    matches!(ch,
454
573560
        '\u{2E80}'..='\u{2E99}'
455
573367
        | '\u{2E9B}'..='\u{2EF3}'
456
572656
        | '\u{2F00}'..='\u{2FD5}'
457
        | '\u{3005}'
458
        | '\u{3007}'
459
570617
        | '\u{3021}'..='\u{3029}'
460
570453
        | '\u{3038}'..='\u{303B}'
461
563934
        | '\u{3400}'..='\u{4DB5}'
462
517146
        | '\u{4E00}'..='\u{9FCC}'
463
237826
        | '\u{F900}'..='\u{FA6D}'
464
235234
        | '\u{FA70}'..='\u{FAD9}'
465
    )
466
642132
}
467

            
468
436155
#[must_use] pub const fn is_bengali(ch: char) -> bool {
469
436155
    matches!(ch, '\u{0980}'..='\u{09FF}')
470
436155
}
471

            
472
365087
#[must_use] pub const fn is_hiragana(ch: char) -> bool {
473
365087
    matches!(ch, '\u{3040}'..='\u{309F}')
474
365087
}
475

            
476
364689
#[must_use] pub const fn is_katakana(ch: char) -> bool {
477
364689
    matches!(ch,
478
303658
        '\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
231939
        | '\u{FF66}'..='\u{FF9F}'
482
    )
483
364689
}
484

            
485
// Hangul is Korean Alphabet. Unicode ranges are taken from: https://en.wikipedia.org/wiki/Hangul
486
447617
#[must_use] pub const fn is_hangul(ch: char) -> bool {
487
447617
    matches!(ch,
488
358733
        '\u{AC00}'..='\u{D7AF}'
489
353096
        | '\u{1100}'..='\u{11FF}'
490
304631
        | '\u{3130}'..='\u{318F}'
491
284096
        | '\u{A960}'..='\u{A97F}'
492
280150
        | '\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
231841
        | '\u{FFA0}'..='\u{FFDC}'
499
    )
500
447617
}
501

            
502
// Taken from: https://en.wikipedia.org/wiki/Greek_and_Coptic
503
363620
#[must_use] pub const fn is_greek(ch: char) -> bool {
504
363620
    matches!(ch, '\u{0370}'..='\u{03FF}')
505
363620
}
506

            
507
// Based on: https://en.wikipedia.org/wiki/Kannada_(Unicode_block)
508
362719
#[must_use] pub const fn is_kannada(ch: char) -> bool {
509
362719
    matches!(ch, '\u{0C80}'..='\u{0CFF}')
510
362719
}
511

            
512
// Based on: https://en.wikipedia.org/wiki/Tamil_(Unicode_block)
513
361943
#[must_use] pub const fn is_tamil(ch: char) -> bool {
514
361943
    matches!(ch, '\u{0B80}'..='\u{0BFF}')
515
361943
}
516

            
517
// Based on: https://en.wikipedia.org/wiki/Thai_(Unicode_block)
518
361176
#[must_use] pub const fn is_thai(ch: char) -> bool {
519
361176
    matches!(ch, '\u{0E00}'..='\u{0E7F}')
520
361176
}
521

            
522
// Based on: https://en.wikipedia.org/wiki/Gujarati_(Unicode_block)
523
360396
#[must_use] pub const fn is_gujarati(ch: char) -> bool {
524
360396
    matches!(ch, '\u{0A80}'..='\u{0AFF}')
525
360396
}
526

            
527
// Gurmukhi is the script for Punjabi language.
528
// Based on: https://en.wikipedia.org/wiki/Gurmukhi_(Unicode_block)
529
359626
#[must_use] pub const fn is_gurmukhi(ch: char) -> bool {
530
359626
    matches!(ch, '\u{0A00}'..='\u{0A7F}')
531
359626
}
532

            
533
358850
#[must_use] pub const fn is_telugu(ch: char) -> bool {
534
358850
    matches!(ch, '\u{0C00}'..='\u{0C7F}')
535
358850
}
536

            
537
// Based on: https://en.wikipedia.org/wiki/Malayalam_(Unicode_block)
538
358078
#[must_use] pub const fn is_malayalam(ch: char) -> bool {
539
358078
    matches!(ch, '\u{0D00}'..='\u{0D7F}')
540
358078
}
541

            
542
// Based on: https://en.wikipedia.org/wiki/Oriya_(Unicode_block)
543
357309
#[must_use] pub const fn is_oriya(ch: char) -> bool {
544
357309
    matches!(ch, '\u{0B00}'..='\u{0B7F}')
545
357309
}
546

            
547
// Based on: https://en.wikipedia.org/wiki/Myanmar_(Unicode_block)
548
356561
#[must_use] pub const fn is_myanmar(ch: char) -> bool {
549
356561
    matches!(ch, '\u{1000}'..='\u{109F}')
550
356561
}
551

            
552
// Based on: https://en.wikipedia.org/wiki/Sinhala_(Unicode_block)
553
355555
#[must_use] pub const fn is_sinhala(ch: char) -> bool {
554
355555
    matches!(ch, '\u{0D80}'..='\u{0DFF}')
555
355555
}
556

            
557
// Based on: https://en.wikipedia.org/wiki/Khmer_alphabet
558
354822
#[must_use] pub const fn is_khmer(ch: char) -> bool {
559
354822
    matches!(ch, '\u{1780}'..='\u{17FF}' | '\u{19E0}'..='\u{19FF}')
560
354822
}
561

            
562
#[cfg(test)]
563
mod script_class_tests {
564
    use super::{detect_script, is_hangul, is_katakana, Script};
565

            
566
    #[test]
567
1
    fn halfwidth_katakana_is_not_hangul() {
568
        // U+FF71..FF73 = halfwidth katakana アイウ — must classify as Katakana, not Hangul.
569
4
        for ch in ['\u{FF71}', '\u{FF72}', '\u{FF73}'] {
570
3
            assert!(!is_hangul(ch), "{ch:?} wrongly matched is_hangul");
571
3
            assert!(is_katakana(ch), "{ch:?} should match is_katakana");
572
        }
573
1
        assert_eq!(detect_script("\u{FF71}\u{FF72}\u{FF73}"), Some(Script::Katakana));
574
1
    }
575

            
576
    #[test]
577
1
    fn fullwidth_latin_is_not_hangul() {
578
        // U+FF21..FF23 = fullwidth ABC — must not be classified as Hangul.
579
4
        for ch in ['\u{FF21}', '\u{FF22}', '\u{FF23}'] {
580
3
            assert!(!is_hangul(ch), "{ch:?} wrongly matched is_hangul");
581
        }
582
1
        assert_ne!(detect_script("\u{FF21}\u{FF22}\u{FF23}"), Some(Script::Hangul));
583
1
    }
584

            
585
    #[test]
586
1
    fn real_hangul_still_detected() {
587
1
        assert!(is_hangul('\u{AC00}')); // 가
588
1
        assert_eq!(detect_script("\u{AC00}\u{AC01}"), Some(Script::Hangul));
589
1
    }
590
}
591

            
592
#[cfg(test)]
593
#[allow(clippy::unicode_not_nfc, clippy::non_ascii_literal)]
594
mod 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() {
            if detect_char_script(ch).is_none() {
                for (script, check_fn) in SCRIPT_CHECKERS {
                    assert!(
                        !check_fn(ch),
                        "U+{:04X} is unclassified yet {script:?}'s checker claims it",
                        ch as u32
                    );
                }
            }
        }
    }
    #[test]
    fn detect_char_script_some_implies_that_scripts_checker_matched() {
        for ch in bmp_chars() {
            if let Some(script) = detect_char_script(ch) {
                let (_, check_fn) = SCRIPT_CHECKERS[script_index_in_table(script)];
                assert!(
                    check_fn(ch),
                    "detect_char_script said {script:?} for U+{:04X} but its checker says no",
                    ch as u32
                );
            }
        }
    }
    fn script_index_in_table(script: Script) -> usize {
        SCRIPT_CHECKERS
            .iter()
            .position(|&(s, _)| s == script)
            .expect("every Script must appear in SCRIPT_CHECKERS")
    }
    #[test]
    fn script_checkers_table_covers_every_script_exactly_once() {
        assert_eq!(SCRIPT_CHECKERS.len(), ALL_SCRIPTS.len());
        let mut seen = [0usize; 24];
        for (script, _) in SCRIPT_CHECKERS {
            seen[script_index(script)] += 1;
        }
        for (i, count) in seen.iter().enumerate() {
            assert_eq!(*count, 1, "{:?} appears {count} times in SCRIPT_CHECKERS", ALL_SCRIPTS[i]);
        }
    }
    #[test]
    fn every_script_is_reachable_from_some_bmp_char() {
        // Guards against a checker being fully shadowed by an earlier, broader one:
        // if some script can never be produced, the table order has swallowed it.
        let mut reachable = [false; 24];
        for ch in bmp_chars() {
            if let Some(script) = detect_char_script(ch) {
                reachable[script_index(script)] = true;
            }
        }
        for (i, ok) in reachable.iter().enumerate() {
            assert!(*ok, "{:?} is unreachable — shadowed by an earlier checker", ALL_SCRIPTS[i]);
        }
    }
    #[test]
    fn overlapping_ranges_resolve_to_the_first_checker_in_the_table() {
        // U+1D2B (CYRILLIC LETTER SMALL CAPITAL EL) and U+1D78 (MODIFIER LETTER
        // CYRILLIC EN) are listed by *both* is_latin (via U+1D00..=U+1D7F) and
        // is_cyrillic. Latin is checked first, so Latin wins. Pinned here because a
        // reordering of SCRIPT_CHECKERS would silently flip these to Cyrillic.
        for ch in ['\u{1D2B}', '\u{1D78}'] {
            assert!(is_latin(ch), "{ch:?} in is_latin's U+1D00..=U+1D7F range");
            assert!(is_cyrillic(ch), "{ch:?} is explicitly listed by is_cyrillic");
            assert_eq!(detect_char_script(ch), Some(Script::Latin));
            assert_eq!(detect_script(&ch.to_string()), Some(Script::Latin));
        }
    }
    // ---------------------------------------------------------------------
    // is_* predicates: exact range boundaries
    // ---------------------------------------------------------------------
    /// Assert a predicate accepts both ends and the midpoint of `[lo, hi]`. The chars
    /// bracketing the range are checked separately with `assert_rejects`, because a
    /// neighbour may legitimately belong to another range of the *same* predicate.
    fn assert_range(name: &str, f: fn(char) -> bool, lo: u32, hi: u32) {
        for cp in [lo, hi] {
            let ch = char::from_u32(cp).unwrap_or_else(|| panic!("{name}: U+{cp:04X} not a char"));
            assert!(f(ch), "{name} should accept its boundary U+{cp:04X}");
        }
        let mid = char::from_u32(lo + (hi - lo) / 2).unwrap();
        assert!(f(mid), "{name} should accept its midpoint {mid:?}");
    }
    fn assert_rejects(name: &str, f: fn(char) -> bool, cps: &[u32]) {
        for &cp in cps {
            let Some(ch) = char::from_u32(cp) else { continue };
            assert!(!f(ch), "{name} should reject U+{cp:04X}");
        }
    }
    #[test]
    fn single_range_predicates_have_exact_boundaries() {
        // (name, fn, start, end): each of these is a single contiguous block, so the
        // chars immediately before/after must be rejected.
        let cases: [(&str, fn(char) -> bool, u32, u32); 12] = [
            ("is_hebrew", is_hebrew, 0x0590, 0x05FF),
            ("is_georgian", is_georgian, 0x10A0, 0x10FF),
            ("is_bengali", is_bengali, 0x0980, 0x09FF),
            ("is_hiragana", is_hiragana, 0x3040, 0x309F),
            ("is_greek", is_greek, 0x0370, 0x03FF),
            ("is_kannada", is_kannada, 0x0C80, 0x0CFF),
            ("is_tamil", is_tamil, 0x0B80, 0x0BFF),
            ("is_thai", is_thai, 0x0E00, 0x0E7F),
            ("is_gujarati", is_gujarati, 0x0A80, 0x0AFF),
            ("is_gurmukhi", is_gurmukhi, 0x0A00, 0x0A7F),
            ("is_telugu", is_telugu, 0x0C00, 0x0C7F),
            ("is_malayalam", is_malayalam, 0x0D00, 0x0D7F),
        ];
        for (name, f, lo, hi) in cases {
            assert_range(name, f, lo, hi);
            assert_rejects(name, f, &[lo - 1, hi + 1, 0x0000, 0x0041, 0x10_FFFF]);
        }
        // The two remaining single-range predicates, spelled out (0x0B00-1 etc. all
        // land in neighbouring script blocks, which is exactly what we want to check).
        assert_range("is_oriya", is_oriya, 0x0B00, 0x0B7F);
        assert_rejects("is_oriya", is_oriya, &[0x0AFF, 0x0B80]);
        assert_range("is_myanmar", is_myanmar, 0x1000, 0x109F);
        assert_rejects("is_myanmar", is_myanmar, &[0x0FFF, 0x10A0]);
        assert_range("is_sinhala", is_sinhala, 0x0D80, 0x0DFF);
        assert_rejects("is_sinhala", is_sinhala, &[0x0D7F, 0x0E00]);
    }
    #[test]
    fn is_latin_boundaries() {
        assert!(is_latin('a') && is_latin('z') && is_latin('A') && is_latin('Z'));
        // The chars bracketing the ASCII letter ranges are all stop chars.
        assert_rejects("is_latin", is_latin, &[0x0040, 0x005B, 0x0060, 0x007B, 0x007F]);
        assert_range("is_latin", is_latin, 0x0080, 0x024F); // Latin-1 Sup .. Latin Ext-B
        assert_range("is_latin", is_latin, 0x0250, 0x02AF); // IPA extensions
        assert_rejects("is_latin", is_latin, &[0x02B0, 0x0300, 0x0400, 0x1CFF]);
        assert_range("is_latin", is_latin, 0x1D00, 0x1DBF);
        assert_rejects("is_latin", is_latin, &[0x1DC0]);
        assert_range("is_latin", is_latin, 0x1E00, 0x1EFF);
        assert_rejects("is_latin", is_latin, &[0x1DFF, 0x1F00]);
        assert_range("is_latin", is_latin, 0x2100, 0x214F);
        assert_rejects("is_latin", is_latin, &[0x20FF, 0x2150]);
        assert_range("is_latin", is_latin, 0x2C60, 0x2C7F);
        assert_rejects("is_latin", is_latin, &[0x2C5F, 0x2C80]);
        assert_range("is_latin", is_latin, 0xA720, 0xA7FF);
        assert_rejects("is_latin", is_latin, &[0xA71F, 0xA800]);
        assert_range("is_latin", is_latin, 0xAB30, 0xAB6F);
        assert_rejects("is_latin", is_latin, &[0xAB2F, 0xAB70]);
    }
    #[test]
    fn is_latin_swallows_latin1_symbols_and_letterlike_forms() {
        // Pinned quirk, not an endorsement: is_latin's U+0080..=U+00FF and
        // U+2100..=U+214F ranges are whole *blocks*, so NBSP, ©, ×, ÷, ™ and ℃ all
        // report as Latin and score for Latin in detect_script.
        for ch in ['\u{00A0}', '\u{00A9}', '\u{00D7}', '\u{00F7}', '\u{2122}', '\u{2103}'] {
            assert!(is_latin(ch), "U+{:04X} is inside is_latin's block ranges", ch as u32);
            assert_eq!(detect_script(&ch.to_string()), Some(Script::Latin));
        }
    }
    #[test]
    fn is_cyrillic_boundaries_including_the_titlo_gap() {
        assert_range("is_cyrillic", is_cyrillic, 0x0400, 0x0484);
        // U+0485/U+0486 (combining Cyrillic titlo) are deliberately excluded.
        assert_rejects("is_cyrillic", is_cyrillic, &[0x03FF, 0x0485, 0x0486, 0x0530]);
        assert_range("is_cyrillic", is_cyrillic, 0x0487, 0x052F);
        assert_range("is_cyrillic", is_cyrillic, 0x2DE0, 0x2DFF);
        assert_rejects("is_cyrillic", is_cyrillic, &[0x2DDF, 0x2E00]);
        assert_range("is_cyrillic", is_cyrillic, 0xA640, 0xA69D);
        assert!(is_cyrillic('\u{A69F}'));
        assert_rejects("is_cyrillic", is_cyrillic, &[0xA63F, 0xA69E, 0xA6A0]);
        assert!(is_cyrillic('\u{1D2B}') && is_cyrillic('\u{1D78}'));
    }
    #[test]
    fn is_arabic_boundaries_and_the_bom() {
        assert_range("is_arabic", is_arabic, 0x0600, 0x06FF);
        assert_rejects("is_arabic", is_arabic, &[0x05FF, 0x0700, 0x074F, 0x0800, 0x089F]);
        assert_range("is_arabic", is_arabic, 0x0750, 0x07FF);
        assert_range("is_arabic", is_arabic, 0x08A0, 0x08FF);
        assert_range("is_arabic", is_arabic, 0xFB50, 0xFDFF);
        assert_range("is_arabic", is_arabic, 0xFE70, 0xFEFF);
        assert_rejects("is_arabic", is_arabic, &[0xFB4F, 0xFE00, 0xFE6F, 0xFF00]);
        assert_range("is_arabic", is_arabic, 0x1_0E60, 0x1_0E7F);
        assert_range("is_arabic", is_arabic, 0x1_EE00, 0x1_EEFF);
        assert_rejects("is_arabic", is_arabic, &[0x1_0E5F, 0x1_0E80, 0x1_EDFF, 0x1_EF00]);
        // BUG PIN: U+FEFF is the byte-order mark / ZERO WIDTH NO-BREAK SPACE, whose
        // Unicode script is Common — but it sits at the top of the Arabic
        // Presentation Forms-B block, so is_arabic claims it. A BOM-prefixed text is
        // therefore scored as containing one Arabic char. Behaviour pinned as-is;
        // see the report.
        assert!(is_arabic('\u{FEFF}'));
        assert_eq!(detect_script("\u{FEFF}"), Some(Script::Arabic));
        // The BOM is not enough to beat a real majority, at least.
        assert_eq!(detect_script("\u{FEFF}hello"), Some(Script::Latin));
    }
    #[test]
    fn is_devanagari_boundaries() {
        assert_range("is_devanagari", is_devanagari, 0x0900, 0x097F);
        assert_range("is_devanagari", is_devanagari, 0xA8E0, 0xA8FF);
        assert_range("is_devanagari", is_devanagari, 0x1CD0, 0x1CFF); // Vedic extensions
        assert_rejects(
            "is_devanagari",
            is_devanagari,
            &[0x08FF, 0x0980, 0xA8DF, 0xA900, 0x1CCF, 0x1D00],
        );
    }
    #[test]
    fn is_ethiopic_boundaries() {
        assert_range("is_ethiopic", is_ethiopic, 0x1200, 0x139F);
        assert_range("is_ethiopic", is_ethiopic, 0x2D80, 0x2DDF);
        assert_range("is_ethiopic", is_ethiopic, 0xAB00, 0xAB2F);
        assert_rejects("is_ethiopic", is_ethiopic, &[0x11FF, 0x13A0, 0x2D7F, 0xAAFF]);
        // U+2DE0 is where Cyrillic Extended-A starts — must NOT be Ethiopic.
        assert!(!is_ethiopic('\u{2DE0}'));
        assert!(is_cyrillic('\u{2DE0}'));
        // U+AB30 is where is_latin's Latin Extended-E range starts.
        assert!(!is_ethiopic('\u{AB30}'));
        assert!(is_latin('\u{AB30}'));
    }
    #[test]
    fn is_mandarin_boundaries_and_gaps() {
        assert_range("is_mandarin", is_mandarin, 0x2E80, 0x2E99);
        assert!(!is_mandarin('\u{2E9A}')); // documented hole in the CJK Radicals block
        assert_range("is_mandarin", is_mandarin, 0x2E9B, 0x2EF3);
        assert_range("is_mandarin", is_mandarin, 0x2F00, 0x2FD5);
        assert!(is_mandarin('\u{3005}') && is_mandarin('\u{3007}'));
        assert!(!is_mandarin('\u{3006}')); // U+3006 IDEOGRAPHIC CLOSING MARK is excluded
        assert_range("is_mandarin", is_mandarin, 0x3021, 0x3029);
        assert_range("is_mandarin", is_mandarin, 0x3038, 0x303B);
        assert_range("is_mandarin", is_mandarin, 0x3400, 0x4DB5);
        assert_range("is_mandarin", is_mandarin, 0x4E00, 0x9FCC);
        assert_range("is_mandarin", is_mandarin, 0xF900, 0xFA6D);
        assert_range("is_mandarin", is_mandarin, 0xFA70, 0xFAD9);
        assert_rejects(
            "is_mandarin",
            is_mandarin,
            &[0x2E7F, 0x2EF4, 0x2FD6, 0x3004, 0x4DB6, 0x9FCD, 0xF8FF, 0xFA6E, 0xFADA],
        );
    }
    #[test]
    fn is_katakana_and_is_hangul_do_not_overlap_in_halfwidth_forms() {
        assert_range("is_katakana", is_katakana, 0x30A0, 0x30FF);
        assert_range("is_katakana", is_katakana, 0xFF66, 0xFF9F);
        assert_rejects("is_katakana", is_katakana, &[0x309F, 0x3100, 0xFF65, 0xFFA0]);
        assert_range("is_hangul", is_hangul, 0xAC00, 0xD7AF);
        assert_range("is_hangul", is_hangul, 0x1100, 0x11FF);
        assert_range("is_hangul", is_hangul, 0x3130, 0x318F);
        assert_range("is_hangul", is_hangul, 0xA960, 0xA97F);
        assert_range("is_hangul", is_hangul, 0xD7B0, 0xD7FF);
        assert_range("is_hangul", is_hangul, 0xFFA0, 0xFFDC);
        assert_rejects(
            "is_hangul",
            is_hangul,
            &[0x10FF, 0x1200, 0x312F, 0x3190, 0x3200, 0xABFF, 0xFF66, 0xFF9F, 0xFFDD, 0xFFE0],
        );
        // The two halfwidth ranges must stay disjoint.
        for cp in 0xFF61u32..=0xFFDCu32 {
            let ch = char::from_u32(cp).unwrap();
            assert!(
                !(is_katakana(ch) && is_hangul(ch)),
                "U+{cp:04X} claimed by both is_katakana and is_hangul"
            );
        }
    }
    #[test]
    fn is_khmer_boundaries() {
        assert_range("is_khmer", is_khmer, 0x1780, 0x17FF);
        assert_range("is_khmer", is_khmer, 0x19E0, 0x19FF);
        assert_rejects("is_khmer", is_khmer, &[0x177F, 0x1800, 0x19DF, 0x1A00]);
    }
    #[test]
    fn predicates_reject_the_extremes_and_are_pure() {
        for (script, check_fn) in SCRIPT_CHECKERS {
            for ch in ['\u{0000}', ' ', '0', '\u{007F}', '\u{10FFFF}'] {
                let first = check_fn(ch);
                assert_eq!(first, check_fn(ch), "{script:?} checker is not pure for {ch:?}");
                assert!(!first, "{script:?} checker claims the non-letter {ch:?}");
            }
        }
    }
    // ---------------------------------------------------------------------
    // detect_bengali_language
    // ---------------------------------------------------------------------
    #[test]
    fn detect_bengali_language_defaults_to_bengali() {
        assert_eq!(detect_bengali_language(""), Language::Bengali);
        assert_eq!(detect_bengali_language("   "), Language::Bengali);
        assert_eq!(detect_bengali_language("আমার সোনার বাংলা"), Language::Bengali);
        // Out-of-script text is not validated — it still falls through to Bengali.
        assert_eq!(detect_bengali_language("hello"), Language::Bengali);
        assert_eq!(detect_bengali_language("\u{1F600}"), Language::Bengali);
    }
    #[test]
    fn detect_bengali_language_finds_assamese_at_any_position() {
        for text in ["\u{09F0}", "\u{09F1}", "\u{09F0}আমার", "আমার\u{09F0}", "আ\u{09F1}র"] {
            assert_eq!(detect_bengali_language(text), Language::Assamese, "text {text:?}");
        }
        // Boundary: the code points either side of the ৰ/ৱ pair are plain Bengali.
        assert_eq!(detect_bengali_language("\u{09EF}"), Language::Bengali);
        assert_eq!(detect_bengali_language("\u{09F2}"), Language::Bengali);
    }
    #[test]
    fn detect_bengali_language_long_input_terminates() {
        let long = "আ".repeat(200_000);
        assert_eq!(detect_bengali_language(&long), Language::Bengali);
        // Assamese marker at the very end — worst case for the early-return scan.
        let mut with_marker = long.clone();
        with_marker.push('\u{09F0}');
        assert_eq!(detect_bengali_language(&with_marker), Language::Assamese);
    }
    // ---------------------------------------------------------------------
    // detect_cyrillic_language
    // ---------------------------------------------------------------------
    #[test]
    fn detect_cyrillic_language_defaults_to_russian() {
        assert_eq!(detect_cyrillic_language(""), Language::Russian);
        assert_eq!(detect_cyrillic_language("Привет мир"), Language::Russian);
        assert_eq!(detect_cyrillic_language("hello"), Language::Russian);
        assert_eq!(detect_cyrillic_language("\u{1F600}"), Language::Russian);
    }
    #[test]
    fn detect_cyrillic_language_markers() {
        let cases: [(&str, Language); 7] = [
            ("\u{0460}", Language::SlavonicChurch),
            ("ѓ", Language::Macedonian),
            ("ў", Language::Belarusian),
            ("ї", Language::Ukrainian),
            ("ө", Language::Mongolian),
            ("ј", Language::SerbianCyrillic),
            ("щ", Language::Bulgarian),
        ];
        for (text, expected) in cases {
            assert_eq!(detect_cyrillic_language(text), expected, "text {text:?}");
        }
        // Old-Cyrillic block boundaries: U+0460..=U+047F inclusive, nothing outside.
        assert_eq!(detect_cyrillic_language("\u{047F}"), Language::SlavonicChurch);
        assert_eq!(detect_cyrillic_language("\u{0480}"), Language::Russian);
        // U+045F sits one below the Old-Cyrillic range — and it is 'џ', so it falls
        // through to the Serbian arm rather than to the Russian default.
        assert_eq!(detect_cyrillic_language("\u{045F}"), Language::SerbianCyrillic);
    }
    #[test]
    fn detect_cyrillic_language_is_positional_not_priority_ordered() {
        // The comments in the function claim Old-Cyrillic is the "highest priority"
        // check, but every arm returns immediately, so the *first marker char in the
        // text* wins regardless of its claimed rank. Pinned; see the report.
        assert_eq!(detect_cyrillic_language("щ\u{0460}"), Language::Bulgarian);
        assert_eq!(detect_cyrillic_language("\u{0460}щ"), Language::SlavonicChurch);
        assert_eq!(detect_cyrillic_language("ўщ"), Language::Belarusian);
        assert_eq!(detect_cyrillic_language("щў"), Language::Bulgarian);
    }
    #[test]
    fn detect_cyrillic_language_long_input_terminates() {
        let long = "а".repeat(200_000);
        assert_eq!(detect_cyrillic_language(&long), Language::Russian);
        let mut trailing = long;
        trailing.push('\u{0460}');
        assert_eq!(detect_cyrillic_language(&trailing), Language::SlavonicChurch);
    }
    // ---------------------------------------------------------------------
    // detect_devanagari_language
    // ---------------------------------------------------------------------
    #[test]
    fn detect_devanagari_language_defaults_to_hindi() {
        assert_eq!(detect_devanagari_language(""), Language::Hindi);
        assert_eq!(detect_devanagari_language("नमस्ते"), Language::Hindi);
        assert_eq!(detect_devanagari_language("hello"), Language::Hindi);
    }
    #[test]
    fn detect_devanagari_language_markers_and_boundaries() {
        assert_eq!(detect_devanagari_language("\u{0933}"), Language::Marathi); // ळ
        assert_eq!(detect_devanagari_language("\u{1CD0}"), Language::Sanskrit);
        assert_eq!(detect_devanagari_language("\u{1CFF}"), Language::Sanskrit);
        assert_eq!(detect_devanagari_language("\u{1CCF}"), Language::Hindi);
        assert_eq!(detect_devanagari_language("\u{1D00}"), Language::Hindi);
        assert_eq!(detect_devanagari_language("\u{0932}"), Language::Hindi);
        assert_eq!(detect_devanagari_language("\u{0934}"), Language::Hindi);
        // Positional, not priority-ordered — whichever marker comes first wins.
        assert_eq!(detect_devanagari_language("\u{1CD0}\u{0933}"), Language::Sanskrit);
        assert_eq!(detect_devanagari_language("\u{0933}\u{1CD0}"), Language::Marathi);
    }
    #[test]
    fn detect_devanagari_language_long_input_terminates() {
        let long = "न".repeat(200_000);
        assert_eq!(detect_devanagari_language(&long), Language::Hindi);
    }
    // ---------------------------------------------------------------------
    // detect_greek_language
    // ---------------------------------------------------------------------
    #[test]
    fn detect_greek_language_defaults_to_monotonic() {
        assert_eq!(detect_greek_language(""), Language::GreekMono);
        assert_eq!(detect_greek_language("Γειά σου"), Language::GreekMono);
        assert_eq!(detect_greek_language("hello"), Language::GreekMono);
    }
    #[test]
    fn detect_greek_language_markers_and_boundaries() {
        assert_eq!(detect_greek_language("\u{2C80}"), Language::Coptic);
        assert_eq!(detect_greek_language("\u{2CFF}"), Language::Coptic);
        assert_eq!(detect_greek_language("\u{2C7F}"), Language::GreekMono);
        assert_eq!(detect_greek_language("\u{2D00}"), Language::GreekMono);
        assert_eq!(detect_greek_language("\u{1F00}"), Language::GreekPoly);
        assert_eq!(detect_greek_language("\u{1FFF}"), Language::GreekPoly);
        assert_eq!(detect_greek_language("\u{1EFF}"), Language::GreekMono);
        assert_eq!(detect_greek_language("\u{2000}"), Language::GreekMono);
        // Positional, not priority-ordered.
        assert_eq!(detect_greek_language("\u{1F00}\u{2C80}"), Language::GreekPoly);
        assert_eq!(detect_greek_language("\u{2C80}\u{1F00}"), Language::Coptic);
    }
    #[test]
    fn detect_greek_language_long_input_terminates() {
        let long = "α".repeat(200_000);
        assert_eq!(detect_greek_language(&long), Language::GreekMono);
    }
    // ---------------------------------------------------------------------
    // detect_latin_language
    // ---------------------------------------------------------------------
    #[test]
    fn detect_latin_language_defaults_to_english() {
        assert_eq!(detect_latin_language(""), Language::EnglishUS);
        assert_eq!(detect_latin_language("the quick brown fox"), Language::EnglishUS);
        assert_eq!(detect_latin_language("0123456789 !@#$%"), Language::EnglishUS);
        assert_eq!(detect_latin_language("\u{1F600}"), Language::EnglishUS);
        // Non-Latin text is not validated — it still falls through to English.
        assert_eq!(detect_latin_language("你好"), Language::EnglishUS);
    }
    #[test]
    fn detect_latin_language_single_char_markers() {
        let cases: [(char, Language); 17] = [
            ('ß', Language::German1996),
            ('ä', Language::German1996),
            ('ő', Language::Hungarian),
            ('ł', Language::Polish),
            ('ř', Language::Czech),
            ('ľ', Language::Slovak),
            ('ā', Language::Latvian),
            ('ą', Language::Lithuanian),
            ('ă', Language::Romanian),
            ('ğ', Language::Turkish),
            ('đ', Language::Croatian),
            ('þ', Language::Icelandic),
            ('ŵ', Language::Welsh),
            ('æ', Language::NorwegianBokmal),
            ('å', Language::Swedish),
            ('ñ', Language::Spanish),
            ('á', Language::Spanish),
        ];
        for (ch, expected) in cases {
            assert_eq!(detect_latin_language(&ch.to_string()), expected, "char {ch:?}");
            // Position within the text must not matter for early-return markers.
            assert_eq!(detect_latin_language(&format!("word {ch} word")), expected);
        }
    }
    #[test]
    fn detect_latin_language_flag_combinations() {
        // The three deferred flags (ç / õ / ã) drive the French-Estonian-Portuguese
        // tie-break at the end of the scan.
        assert_eq!(detect_latin_language("ç"), Language::French);
        assert_eq!(detect_latin_language("garçon"), Language::French);
        assert_eq!(detect_latin_language("õ"), Language::Estonian);
        assert_eq!(detect_latin_language("õhtu"), Language::Estonian);
        assert_eq!(detect_latin_language("ã"), Language::Portuguese);
        assert_eq!(detect_latin_language("çõ"), Language::Portuguese);
        assert_eq!(detect_latin_language("çã"), Language::Portuguese);
        assert_eq!(detect_latin_language("õã"), Language::Portuguese);
        assert_eq!(detect_latin_language("çõã"), Language::Portuguese);
        assert_eq!(detect_latin_language("informação"), Language::Portuguese);
    }
    #[test]
    fn detect_latin_language_accented_vowel_short_circuits_the_flags() {
        // BUG PIN: 'á'|'é'|'í'|'ó'|'ú' return Spanish *immediately*, so any French or
        // Portuguese word carrying an accented vowel before its ç/õ/ã is reported as
        // Spanish — the flag tie-break never runs. Pinned as-is; see the report.
        assert_eq!(detect_latin_language("café"), Language::Spanish);
        assert_eq!(detect_latin_language("présentation"), Language::Spanish);
        assert_eq!(detect_latin_language("é ç"), Language::Spanish);
        // Order matters: with the ç first, the flag survives to the tie-break — but
        // only because no accented vowel is seen at all.
        assert_eq!(detect_latin_language("ç e"), Language::French);
    }
    #[test]
    fn detect_latin_language_first_marker_wins() {
        assert_eq!(detect_latin_language("ßä"), Language::German1996);
        assert_eq!(detect_latin_language("łß"), Language::Polish);
        assert_eq!(detect_latin_language("åæ"), Language::Swedish);
        assert_eq!(detect_latin_language("æå"), Language::NorwegianBokmal);
    }
    #[test]
    fn detect_latin_language_long_input_terminates() {
        let long = "a".repeat(500_000);
        assert_eq!(detect_latin_language(&long), Language::EnglishUS);
        // Marker at the very end — no early return until the last char.
        let mut trailing = long;
        trailing.push('ß');
        assert_eq!(detect_latin_language(&trailing), Language::German1996);
    }
    // ---------------------------------------------------------------------
    // script_to_language
    // ---------------------------------------------------------------------
    #[test]
    fn script_to_language_is_total_over_every_script() {
        // No script/text combination may panic, and the result must be deterministic.
        let texts = [
            "",
            " ",
            "hello",
            "\u{1F600}",
            "\u{0000}\u{FFFF}\u{10FFFF}",
            "ß ç õ ã щ ळ \u{09F0} \u{2C80}",
        ];
        for script in ALL_SCRIPTS {
            for text in texts {
                let first = script_to_language(script, text);
                let second = script_to_language(script, text);
                assert_eq!(first, second, "{script:?} + {text:?} is not deterministic");
            }
        }
    }
    #[test]
    fn script_to_language_direct_mappings_ignore_the_text() {
        // These 19 scripts map to a fixed language; the text argument must not matter,
        // not even for text stuffed with every other script's marker chars.
        let cases: [(Script, Language); 19] = [
            (Script::Ethiopic, Language::Ethiopic),
            (Script::Georgian, Language::Georgian),
            (Script::Gujarati, Language::Gujarati),
            (Script::Gurmukhi, Language::Panjabi),
            (Script::Kannada, Language::Kannada),
            (Script::Malayalam, Language::Malayalam),
            (Script::Mandarin, Language::Chinese),
            (Script::Oriya, Language::Oriya),
            (Script::Tamil, Language::Tamil),
            (Script::Telugu, Language::Telugu),
            (Script::Thai, Language::Thai),
            (Script::Myanmar, Language::Thai),
            (Script::Khmer, Language::Thai),
            (Script::Sinhala, Language::Hindi),
            (Script::Arabic, Language::Chinese),
            (Script::Hebrew, Language::Chinese),
            (Script::Hangul, Language::Chinese),
            (Script::Hiragana, Language::Chinese),
            (Script::Katakana, Language::Chinese),
        ];
        let long = "x".repeat(10_000);
        for (script, expected) in cases {
            for text in ["", "ß щ ळ \u{09F0} \u{2C80} \u{1F600}", long.as_str()] {
                assert_eq!(
                    script_to_language(script, text),
                    expected,
                    "{script:?} must map to {expected:?} regardless of the text"
                );
            }
        }
    }
    #[test]
    fn script_to_language_delegates_the_five_text_sensitive_scripts() {
        let probes = ["", "hello", "ß", "щ", "\u{0933}", "\u{2C80}", "\u{09F0}"];
        for text in probes {
            assert_eq!(
                script_to_language(Script::Bengali, text),
                detect_bengali_language(text),
                "Bengali delegation broke for {text:?}"
            );
            assert_eq!(
                script_to_language(Script::Cyrillic, text),
                detect_cyrillic_language(text),
                "Cyrillic delegation broke for {text:?}"
            );
            assert_eq!(
                script_to_language(Script::Devanagari, text),
                detect_devanagari_language(text),
                "Devanagari delegation broke for {text:?}"
            );
            assert_eq!(
                script_to_language(Script::Greek, text),
                detect_greek_language(text),
                "Greek delegation broke for {text:?}"
            );
            assert_eq!(
                script_to_language(Script::Latin, text),
                detect_latin_language(text),
                "Latin delegation broke for {text:?}"
            );
        }
    }
    #[test]
    fn detect_script_then_script_to_language_end_to_end() {
        let cases: [(&str, Script, Language); 6] = [
            ("straße", Script::Latin, Language::German1996),
            ("Привет", Script::Cyrillic, Language::Russian),
            ("Здравейте, щастие", Script::Cyrillic, Language::Bulgarian),
            ("你好世界", Script::Mandarin, Language::Chinese),
            ("สวัสดี", Script::Thai, Language::Thai),
            ("ಕನ್ನಡ", Script::Kannada, Language::Kannada),
        ];
        for (text, script, language) in cases {
            let detected = detect_script(text).unwrap_or_else(|| panic!("no script for {text:?}"));
            assert_eq!(detected, script, "script for {text:?}");
            assert_eq!(script_to_language(detected, text), language, "language for {text:?}");
        }
    }
    #[test]
    fn script_to_language_survives_pseudo_random_text() {
        for seed in [7u64, 0x1234_5678_9ABC_DEF0] {
            let text = lcg_chars(2_000, seed);
            for script in ALL_SCRIPTS {
                let lang = script_to_language(script, &text);
                assert_eq!(lang, script_to_language(script, &text));
            }
        }
    }
}