1use std::collections::{HashMap, HashSet};
11use std::sync::OnceLock;
12
13use indexmap::IndexMap;
14use regex::Regex;
15use serde_json::Value;
16use unicode_general_category::{get_general_category, GeneralCategory};
17
18const SCRIPT_RANGES: &[(&str, &[(u32, u32)])] = &[
20 ("greek", &[(0x0370, 0x03FF), (0x1F00, 0x1FFF)]),
21 (
22 "cyrillic",
23 &[(0x0400, 0x052F), (0x2DE0, 0x2DFF), (0xA640, 0xA69F)],
24 ),
25 ("armenian", &[(0x0530, 0x058F)]),
26 ("hebrew", &[(0x0590, 0x05FF)]),
27 (
28 "arabic",
29 &[
30 (0x0600, 0x06FF),
31 (0x0750, 0x077F),
32 (0x08A0, 0x08FF),
33 (0xFB50, 0xFDFF),
34 (0xFE70, 0xFEFF),
35 ],
36 ),
37 ("devanagari", &[(0x0900, 0x097F), (0xA8E0, 0xA8FF)]),
38 ("bengali", &[(0x0980, 0x09FF)]),
39 ("gurmukhi", &[(0x0A00, 0x0A7F)]),
40 ("gujarati", &[(0x0A80, 0x0AFF)]),
41 ("oriya", &[(0x0B00, 0x0B7F)]),
42 ("tamil", &[(0x0B80, 0x0BFF)]),
43 ("telugu", &[(0x0C00, 0x0C7F)]),
44 ("kannada", &[(0x0C80, 0x0CFF)]),
45 ("malayalam", &[(0x0D00, 0x0D7F)]),
46 ("sinhala", &[(0x0D80, 0x0DFF)]),
47 ("thai", &[(0x0E00, 0x0E7F)]),
48 ("lao", &[(0x0E80, 0x0EFF)]),
49 ("tibetan", &[(0x0F00, 0x0FFF)]),
50 ("myanmar", &[(0x1000, 0x109F)]),
51 ("georgian", &[(0x10A0, 0x10FF)]),
52 ("ethiopic", &[(0x1200, 0x137F)]),
53 ("khmer", &[(0x1780, 0x17FF)]),
54 (
55 "hangul",
56 &[(0x1100, 0x11FF), (0x3130, 0x318F), (0xAC00, 0xD7AF)],
57 ),
58 (
59 "kana",
60 &[(0x3040, 0x309F), (0x30A0, 0x30FF), (0x31F0, 0x31FF)],
61 ),
62 (
63 "han",
64 &[(0x3400, 0x4DBF), (0x4E00, 0x9FFF), (0xF900, 0xFAFF)],
65 ),
66];
67
68pub const NON_EN_DIACRITIC_RATE: f64 = 0.02;
70
71pub const ENGLISH_RESCUE_DIACRITIC_RATE: f64 = 0.06;
77
78pub const NON_LATIN_FRACTION: f64 = 0.2;
80pub const NON_LATIN_MIN_FRACTION: f64 = 0.1;
81pub const NON_LATIN_MIN_LETTERS: i64 = 10;
82
83fn stop() -> &'static IndexMap<&'static str, HashSet<&'static str>> {
85 static S: OnceLock<IndexMap<&'static str, HashSet<&'static str>>> = OnceLock::new();
86 S.get_or_init(|| {
87 let mut m: IndexMap<&'static str, HashSet<&'static str>> = IndexMap::new();
88 m.insert(
89 "en",
90 [
91 "the", "and", "is", "are", "was", "were", "to", "of", "in", "for", "with", "that",
92 "this", "it", "you", "have", "has", "not", "but", "on", "at", "be", "as", "from",
93 "will", "can", "would", "there", "their", "what", "which", "please", "we", "i",
94 ]
95 .into_iter()
96 .collect(),
97 );
98 m.insert(
99 "fr",
100 [
101 "le", "la", "les", "des", "une", "est", "pour", "dans", "que", "qui", "avec",
102 "sur", "pas", "plus", "nous", "vous", "être", "cette", "mais", "sont", "ont",
103 "aux", "ce", "et", "du", "au", "ou", "je", "tu", "il", "elle", "ils", "elles",
104 "mon", "ton", "ma", "ta", "sa", "mes", "tes", "ses", "ces", "deux", "trois",
105 "très", "bien", "tout", "tous", "toute", "fait", "veux", "veut", "peux", "peut",
106 "dois", "doit", "merci", "bonjour", "jour", "jours", "mois", "fois", "quand",
107 "comment", "pourquoi", "alors", "donc",
108 ]
109 .into_iter()
110 .collect(),
111 );
112 m.insert(
113 "de",
114 [
115 "der", "die", "das", "und", "ist", "ein", "eine", "den", "dem", "nicht", "mit",
116 "für", "auf", "von", "zu", "sich", "auch", "werden", "wurde", "haben", "sind",
117 "oder", "aber", "ich", "wir", "mir", "mich", "dir", "dich", "uns", "mein", "meine",
118 "meinen", "meinem", "meiner", "diese", "dieser", "diesen", "dieses", "einen",
119 "einem", "einer", "wie", "wo", "wann", "welche", "im", "zum", "zur", "aus", "bei",
120 "nach", "noch", "bitte", "heute", "jetzt", "kann", "kannst", "habe", "gibt",
121 "wird",
122 "in", "was",
124 ]
125 .into_iter()
126 .collect(),
127 );
128 m.insert(
129 "es",
130 [
131 "el", "los", "las", "que", "por", "con", "para", "una", "es", "se", "del", "como",
132 "pero", "son", "está", "este", "esta", "todo", "más", "muy", "hay", "sus", "la",
133 "un", "y", "al", "lo", "le", "les", "su", "mi", "tu", "nos", "ni", "dos", "tres",
134 "fue", "fueron", "ser", "tiene", "tienen", "tengo", "puede", "pueden", "quiero",
135 "necesito", "hemos", "han", "sobre", "entre", "cuando", "donde", "porque",
136 "aunque", "también", "ya", "eso", "esto", "esa", "ese", "nada", "algo", "aquí",
137 "hoy", "gracias",
138 ]
139 .into_iter()
140 .collect(),
141 );
142 m.insert(
143 "pt",
144 [
145 "os", "as", "que", "em", "um", "uma", "para", "com", "não", "é", "se", "do", "da",
146 "dos", "das", "mas", "são", "está", "este", "esta", "muito", "pelo", "pela", "o",
147 "e", "na", "nas", "nos", "ao", "aos", "por", "foi", "era", "ser", "sou", "tem",
148 "tenho", "pode", "podem", "quero", "preciso", "eu", "meu", "minha", "seu", "sua",
149 "isso", "isto", "aqui", "ali", "como", "quando", "onde", "porque", "mais", "já",
150 "ainda", "agora", "hoje", "ontem", "dois", "três", "tudo", "nada", "obrigado",
151 "olá", "você", "vocês", "voce", "voces", "vc", "vcs", "nao", "sao", "ja", "até",
152 "tá", "pra", "gostaria", "obrigada", "também", "tambem", "estou", "estamos",
153 "meus", "minhas", "nosso", "nossa", "consigo", "cadê", "boa", "tarde", "noite",
154 "depois", "antes", "então", "entao", "ninguém", "ninguem", "alguém", "alguem",
155 "nenhum", "nenhuma", "estava", "ficou", "fiz", "deu",
156 ]
157 .into_iter()
158 .collect(),
159 );
160 m.insert(
161 "it",
162 [
163 "il", "lo", "gli", "che", "di", "per", "con", "non", "è", "si", "del", "della",
164 "sono", "questo", "questa", "anche", "come", "più", "sono", "nella", "alla", "la",
165 "le", "un", "uno", "una", "e", "ed", "o", "da", "su", "tra", "fra", "mi", "ci",
166 "ne", "ho", "hai", "ha", "abbiamo", "avete", "hanno", "era", "stato", "stata",
167 "devo", "deve", "devono", "voglio", "vorrei", "mio", "mia", "tuo", "sua", "quando",
168 "dove", "perche", "molto", "poco", "sempre", "mai", "già", "ancora", "adesso",
169 "oggi", "ieri", "grazie", "ciao", "scusa", "nel", "nell", "negli", "sul", "sulla",
170 "sulle", "dal", "dalla", "dallo", "dagli", "dei", "delle", "dello", "degli",
171 "agli", "alle", "col",
172 ]
173 .into_iter()
174 .collect(),
175 );
176 m.insert(
177 "nl",
178 [
179 "het", "een", "van", "is", "op", "te", "dat", "niet", "met", "voor", "zijn", "aan",
180 "door", "maar", "ook", "worden", "deze", "naar", "wordt",
181 ]
182 .into_iter()
183 .collect(),
184 );
185 m.insert(
186 "ro",
187 [
188 "și", "să", "este", "sunt", "care", "pentru", "din", "dar", "după", "până", "fără",
189 "ale", "lui", "în", "fost", "acum", "vreau", "trebuie", "foarte", "acest",
190 "această", "acesta", "aceasta", "mi", "ți", "vă", "nu",
191 ]
192 .into_iter()
193 .collect(),
194 );
195 m.insert(
196 "bn",
197 [
198 "ami",
199 "amar",
200 "amake",
201 "amra",
202 "amader",
203 "apni",
204 "apnar",
205 "apnake",
206 "apnara",
207 "tumi",
208 "tomar",
209 "tomake",
210 "tomra",
211 "tader",
212 "ota",
213 "eita",
214 "oita",
215 "ekta",
216 "ei",
217 "oi",
218 "ki",
219 "keno",
220 "kivabe",
221 "kibhabe",
222 "kothay",
223 "kokhon",
224 "kobe",
225 "koto",
226 "kintu",
227 "jodi",
228 "tahole",
229 "ar",
230 "theke",
231 "jonno",
232 "sathe",
233 "shathe",
234 "diye",
235 "niye",
236 "moddhe",
237 "kore",
238 "korte",
239 "korchi",
240 "korsi",
241 "korbo",
242 "korechi",
243 "koreche",
244 "korun",
245 "koren",
246 "korlam",
247 "hobe",
248 "hoyeche",
249 "hoise",
250 "hocche",
251 "hoyni",
252 "chai",
253 "chaina",
254 "lagbe",
255 "parchi",
256 "parbo",
257 "parchina",
258 "peyechi",
259 "paini",
260 "dite",
261 "dilam",
262 "diyechi",
263 "nai",
264 "khub",
265 "onek",
266 "ekhon",
267 "akhon",
268 "ekhono",
269 "abar",
270 "ekbar",
271 "duibar",
272 "ajke",
273 "kalke",
274 "taka",
275 "bhalo",
276 "valo",
277 "kharap",
278 "shomossa",
279 "somossa",
280 "dhonnobad",
281 "bhai",
282 "shob",
283 "keu",
284 "kichu",
285 "bolte",
286 "bolun",
287 "parben",
288 "asbe",
289 "jabe",
290 "pabo",
291 "ferot",
292 "dorkar",
293 "hoye",
294 "geche",
295 "gese",
296 ]
297 .into_iter()
298 .collect(),
299 );
300 m.insert(
301 "az",
302 [
303 "və", "ve", "bir", "bu", "üçün", "ucun", "ilə", "ile", "olan", "olub", "olmasa",
304 "var", "yox", "yoxdur", "mən", "sən", "biz", "siz", "onlar", "daha", "çox", "cox",
305 "hər", "nə", "kimi", "görə", "sonra", "əgər", "eger", "deyil", "lakin", "amma",
306 "ancaq", "artıq", "artiq", "də", "isə", "həm", "yalnız", "yalniz",
307 ]
308 .into_iter()
309 .collect(),
310 );
311 m
312 })
313}
314
315const DIACRITICS: &str = concat!(
317 "àâäãáåçéèêëíìîïñóòôöõøúùûüýÿßæœ", "ăâîșțşţ", "ąćęłńśźż", "čďěňřšťůž", "őű", "ğı", "āēģīķļņūž", "đ", "ə", );
327
328fn non_en_diacritics() -> &'static HashSet<char> {
329 static S: OnceLock<HashSet<char>> = OnceLock::new();
330 S.get_or_init(|| DIACRITICS.chars().collect())
331}
332
333fn shared_words() -> &'static HashSet<&'static str> {
335 static S: OnceLock<HashSet<&'static str>> = OnceLock::new();
336 S.get_or_init(|| {
337 let mut counts: HashMap<&'static str, usize> = HashMap::new();
338 for words in stop().values() {
339 for w in words {
340 *counts.entry(*w).or_insert(0) += 1;
341 }
342 }
343 counts
344 .into_iter()
345 .filter(|(_, n)| *n > 1)
346 .map(|(w, _)| w)
347 .collect()
348 })
349}
350
351fn en_only_words() -> &'static HashSet<&'static str> {
354 static S: OnceLock<HashSet<&'static str>> = OnceLock::new();
355 S.get_or_init(|| {
356 let en = &stop()["en"];
357 let shared = shared_words();
358 en.iter().copied().filter(|w| !shared.contains(w)).collect()
359 })
360}
361
362fn english_rescued_by_words(words: &[&str], diac_rate: f64) -> bool {
371 if diac_rate >= ENGLISH_RESCUE_DIACRITIC_RATE {
372 return false;
373 }
374 let word_set: HashSet<&str> = words.iter().copied().collect();
375 let en_only = en_only_words();
376 if word_set.iter().filter(|w| en_only.contains(*w)).count() < 2 {
377 return false;
378 }
379 let diac = non_en_diacritics();
380 word_set
381 .iter()
382 .filter(|w| w.chars().any(|ch| diac.contains(&ch)))
383 .count()
384 <= 1
385}
386
387fn word_re() -> &'static Regex {
388 static R: OnceLock<Regex> = OnceLock::new();
389 R.get_or_init(|| Regex::new(r"[^\W\d_]+").unwrap())
390}
391
392fn identifier_re() -> &'static Regex {
397 static R: OnceLock<Regex> = OnceLock::new();
398 R.get_or_init(|| Regex::new(r"[\w-]*(?:[.@][\w-]+)+").unwrap())
399}
400
401fn is_combining(ch: char) -> bool {
403 matches!(
404 get_general_category(ch),
405 GeneralCategory::NonspacingMark
406 | GeneralCategory::SpacingMark
407 | GeneralCategory::EnclosingMark
408 )
409}
410
411fn is_latin_detect(cp: u32) -> bool {
413 cp < 0x02B0
414 || (0x1E00..=0x1EFF).contains(&cp)
415 || (0xFF21..=0xFF3A).contains(&cp)
416 || (0xFF41..=0xFF5A).contains(&cp)
417}
418
419fn is_latin_script_of(cp: u32) -> bool {
421 cp < 0x0250
422 || (0x1E00..=0x1EFF).contains(&cp)
423 || (0xFF21..=0xFF3A).contains(&cp)
424 || (0xFF41..=0xFF5A).contains(&cp)
425}
426
427fn in_ranges(cp: u32, ranges: &[(u32, u32)]) -> bool {
428 ranges.iter().any(|&(lo, hi)| lo <= cp && cp <= hi)
429}
430
431fn round4(x: f64) -> f64 {
433 round_half_even(x * 10000.0) / 10000.0
434}
435
436fn round_half_even(x: f64) -> f64 {
438 let floor = x.floor();
439 let diff = x - floor;
440 if diff < 0.5 {
441 floor
442 } else if diff > 0.5 {
443 floor + 1.0
444 } else {
445 if (floor as i64) % 2 == 0 {
447 floor
448 } else {
449 floor + 1.0
450 }
451 }
452}
453
454fn iter_text(v: &Value, depth: usize, out: &mut Vec<String>) {
457 if depth > 6 {
458 return;
459 }
460 match v {
461 Value::Null => {}
462 Value::String(s) => out.push(s.clone()),
463 Value::Array(a) => {
464 for x in a {
465 iter_text(x, depth + 1, out);
466 }
467 }
468 Value::Object(m) => {
469 for x in m.values() {
470 iter_text(x, depth + 1, out);
471 }
472 }
473 _ => {}
474 }
475}
476
477pub fn state_text(state: &Value, max_chars: usize) -> String {
479 let mut leaves: Vec<String> = Vec::new();
480 iter_text(state, 0, &mut leaves);
481 let mut parts: Vec<String> = Vec::new();
484 let mut budget = max_chars as isize;
485 for leaf in leaves {
486 if budget <= 0 {
487 break;
488 }
489 let leaf_len = leaf.chars().count() as isize;
490 if leaf_len > budget {
491 parts.push(leaf.chars().take(budget as usize).collect());
492 break;
493 }
494 parts.push(leaf);
495 budget -= leaf_len + 1;
497 }
498 parts.join(" ").chars().take(max_chars).collect()
499}
500
501pub fn detect_script(text: &str) -> String {
505 let mut counts: IndexMap<&'static str, i64> = IndexMap::new();
506 let mut latin: i64 = 0;
507 for ch in text.chars() {
508 if !ch.is_alphabetic() {
509 continue;
510 }
511 let cp = ch as u32;
512 if is_latin_detect(cp) {
513 latin += 1;
514 continue;
515 }
516 let mut found: Option<&'static str> = None;
517 for (name, ranges) in SCRIPT_RANGES {
518 if in_ranges(cp, ranges) {
519 found = Some(name);
520 break;
521 }
522 }
523 let key = found.unwrap_or("other");
524 *counts.entry(key).or_insert(0) += 1;
525 }
526 counts.insert("latin", latin);
527 let total: i64 = counts.values().sum();
528 if total == 0 {
529 return "unknown".to_string();
530 }
531 let mut best: &str = "";
533 let mut best_n: i64 = i64::MIN;
534 for (k, v) in &counts {
535 if *v > best_n {
536 best_n = *v;
537 best = k;
538 }
539 }
540 best.to_string()
541}
542
543pub fn script_profile(text: &str) -> IndexMap<String, f64> {
545 let mut counts: IndexMap<&'static str, i64> = IndexMap::new();
546 counts.insert("latin", 0);
547 for ch in text.chars() {
548 if !ch.is_alphabetic() {
549 continue;
550 }
551 let cp = ch as u32;
552 if is_latin_detect(cp) {
553 *counts.get_mut("latin").unwrap() += 1;
554 continue;
555 }
556 let mut found: Option<&'static str> = None;
557 for (name, ranges) in SCRIPT_RANGES {
558 if in_ranges(cp, ranges) {
559 found = Some(name);
560 break;
561 }
562 }
563 let key = found.unwrap_or("other");
564 *counts.entry(key).or_insert(0) += 1;
565 }
566 let total: i64 = counts.values().sum();
567 let mut out: IndexMap<String, f64> = IndexMap::new();
568 if total == 0 {
569 return out;
570 }
571 for (k, v) in &counts {
572 if *v != 0 {
573 out.insert((*k).to_string(), *v as f64 / total as f64);
574 }
575 }
576 out
577}
578
579fn script_of(ch: char) -> Option<&'static str> {
580 let cp = ch as u32;
581 if is_latin_script_of(cp) {
582 return None;
583 }
584 for (name, ranges) in SCRIPT_RANGES {
585 if in_ranges(cp, ranges) {
586 return Some(name);
587 }
588 }
589 None
590}
591
592fn non_latin_words(text: &str) -> Vec<String> {
594 let mut runs: Vec<String> = Vec::new();
595 let mut cur = String::new();
596 let mut script: Option<&'static str> = None;
597 for ch in text.chars() {
598 if is_combining(ch) {
599 continue;
600 }
601 let s = script_of(ch);
602 if s.is_some() && s == script {
603 cur.push(ch);
604 continue;
605 }
606 if !cur.is_empty() {
607 runs.push(std::mem::take(&mut cur));
608 }
609 match s {
610 Some(_) => {
611 cur = ch.to_string();
612 script = s;
613 }
614 None => {
615 cur = String::new();
616 script = None;
617 }
618 }
619 }
620 if !cur.is_empty() {
621 runs.push(cur);
622 }
623 runs.into_iter()
624 .filter(|w| {
625 w.chars().count() >= 2 && !w.chars().next().map(|c| c.is_uppercase()).unwrap_or(false)
626 })
627 .collect()
628}
629
630#[derive(Debug, Clone)]
634pub struct LatinProfile {
635 pub language: Option<String>,
636 pub english_hits: usize,
637 pub diacritic_rate: f64,
638 pub looks_non_english: bool,
639}
640
641pub fn latin_profile(text: &str) -> LatinProfile {
642 let stripped = identifier_re().replace_all(text, " ");
644 let normalized = stripped.replace('\u{0130}', "i").to_lowercase();
645 let words: Vec<&str> = word_re()
646 .find_iter(&normalized)
647 .map(|m| m.as_str())
648 .collect();
649
650 let lowered = text.to_lowercase();
651 let diac_set = non_en_diacritics();
652 let diac = lowered.chars().filter(|ch| diac_set.contains(ch)).count();
653 let len = lowered.chars().count();
654 let diac_rate = diac as f64 / (std::cmp::max(1, len) as f64);
655 let non_english = diac_rate >= NON_EN_DIACRITIC_RATE;
656
657 if words.len() < 4 {
658 return LatinProfile {
659 language: None,
660 english_hits: 0,
661 diacritic_rate: diac_rate,
662 looks_non_english: non_english,
663 };
664 }
665
666 let stop = stop();
667 let shared = shared_words();
668
669 let mut scores: IndexMap<&'static str, i64> = IndexMap::new();
671 for (lg, sw) in stop {
672 let s = words.iter().filter(|w| sw.contains(*w)).count() as i64;
673 scores.insert(lg, s);
674 }
675 let en = scores.get("en").copied().unwrap_or(0);
676
677 let word_set: HashSet<&str> = words.iter().copied().collect();
678
679 let mut best_lg: Option<&'static str> = None;
681 let mut best: i64 = 0;
682 for (lg, sw) in stop {
683 if *lg == "en" {
684 continue;
685 }
686 let has_evidence = word_set
687 .iter()
688 .any(|w| sw.contains(*w) && !shared.contains(*w));
689 if !has_evidence {
690 continue;
691 }
692 let s = *scores.get(lg).unwrap();
693 if s > best {
694 best = s;
695 best_lg = Some(lg);
696 }
697 }
698
699 let mut language: Option<String> = None;
700 if let Some(bl) = best_lg {
701 if best >= std::cmp::max(2, en + 2) || (non_english && best >= std::cmp::max(2, en)) {
705 language = Some(bl.to_string());
706 } else if en > 0 && (!non_english || english_rescued_by_words(&words, diac_rate)) {
707 language = Some("en".to_string());
708 }
709 } else if en > 0 && (!non_english || english_rescued_by_words(&words, diac_rate)) {
710 language = Some("en".to_string());
711 }
712
713 LatinProfile {
714 language,
715 english_hits: en as usize,
716 diacritic_rate: diac_rate,
717 looks_non_english: non_english,
718 }
719}
720
721pub fn guess_latin_language(text: &str) -> Option<String> {
723 latin_profile(text).language
724}
725
726fn code_line_re() -> &'static Regex {
732 static R: OnceLock<Regex> = OnceLock::new();
733 R.get_or_init(|| Regex::new(r"[=;{}\[\]]|\w\(").unwrap())
734}
735
736fn joined_re() -> &'static Regex {
740 static R: OnceLock<Regex> = OnceLock::new();
741 R.get_or_init(|| Regex::new(r"[^\W_][._/\\][^\W_]").unwrap())
742}
743
744fn letter_run_re() -> &'static Regex {
748 static R: OnceLock<Regex> = OnceLock::new();
749 R.get_or_init(|| Regex::new(r"[^\W\d_]{2,}").unwrap())
750}
751
752fn is_all_upper(s: &str) -> bool {
755 let mut has_cased = false;
756 for c in s.chars() {
757 if c.is_lowercase() {
758 return false;
759 }
760 if c.is_uppercase() {
761 has_cased = true;
762 }
763 }
764 has_cased
765}
766
767fn named_prose_language(segment: &str) -> Option<String> {
773 if segment.trim().is_empty() || code_line_re().is_match(segment) {
774 return None;
775 }
776 let joined = joined_re();
777 let prose = segment
778 .split_whitespace()
779 .filter(|tok| !joined.is_match(tok))
780 .collect::<Vec<_>>()
781 .join(" ");
782 let prose = if prose.chars().any(|c| c.is_lowercase()) {
783 letter_run_re()
784 .replace_all(&prose, |caps: ®ex::Captures| {
785 let m = &caps[0];
786 if is_all_upper(m) {
787 " ".to_string()
788 } else {
789 m.to_string()
790 }
791 })
792 .into_owned()
793 } else {
794 prose
795 };
796 let tokens: Vec<&str> = word_re().find_iter(&prose).map(|m| m.as_str()).collect();
797 if tokens.len() < 4 {
798 return None;
799 }
800 let lang = match latin_profile(&prose).language {
801 Some(l) if l != "en" => l,
802 _ => return None,
803 };
804 let sw = stop().get(lang.as_str())?;
805 let lowered: HashSet<String> = tokens.iter().map(|w| w.to_lowercase()).collect();
806 if lowered.iter().filter(|w| sw.contains(w.as_str())).count() < 2 {
807 return None;
808 }
809 Some(lang)
810}
811
812fn non_english_segment(state: &Value, max_chars: usize) -> Option<(String, String)> {
820 let mut leaves: Vec<String> = Vec::new();
821 iter_text(state, 0, &mut leaves);
822 let mut seen = 0usize;
823 for leaf in &leaves {
824 for seg in leaf.split('\n') {
825 if seen >= max_chars {
826 return None;
827 }
828 let seg: String = seg.chars().take(max_chars - seen).collect();
829 seen += seg.chars().count();
830 if let Some(lang) = named_prose_language(&seg) {
831 return Some((lang, seg.trim().to_string()));
832 }
833 }
834 }
835 None
836}
837
838#[derive(Debug, Clone)]
842pub struct Analysis {
843 pub script: String,
844 pub script_profile: IndexMap<String, f64>,
845 pub language: Option<String>,
846 pub is_english: bool,
847 pub language_undecided: bool,
848 pub diacritic_rate: f64,
849 pub non_latin_fraction: f64,
850 pub mixed_segment: Option<String>,
852}
853
854fn analyse_text(text: &str) -> Analysis {
858 let prof = script_profile(text);
859 let mut script = detect_script(text);
860
861 let non_latin = if !prof.is_empty() {
862 round4(1.0 - prof.get("latin").copied().unwrap_or(0.0))
863 } else {
864 0.0
865 };
866 let sum_alpha = text.chars().filter(|c| c.is_alphabetic()).count() as f64;
867 let n_non_latin = round_half_even(non_latin * sum_alpha) as i64;
868
869 if script == "latin"
870 && !non_latin_words(text).is_empty()
871 && (non_latin >= NON_LATIN_FRACTION
872 || (non_latin >= NON_LATIN_MIN_FRACTION && n_non_latin >= NON_LATIN_MIN_LETTERS))
873 {
874 let mut best: Option<&str> = None;
876 let mut best_v = f64::NEG_INFINITY;
877 for (k, v) in &prof {
878 if k == "latin" {
879 continue;
880 }
881 if *v > best_v {
882 best_v = *v;
883 best = Some(k);
884 }
885 }
886 if let Some(b) = best {
887 script = b.to_string();
888 }
889 }
890
891 if script == "unknown" {
892 return Analysis {
893 script: "unknown".to_string(),
894 script_profile: prof,
895 language: None,
896 is_english: true,
897 language_undecided: true,
898 diacritic_rate: 0.0,
899 non_latin_fraction: 0.0,
900 mixed_segment: None,
901 };
902 }
903 if script != "latin" {
904 return Analysis {
905 script,
906 script_profile: prof,
907 language: None,
908 is_english: false,
909 language_undecided: true,
910 diacritic_rate: 0.0,
911 non_latin_fraction: non_latin,
912 mixed_segment: None,
913 };
914 }
915
916 let prof_lat = latin_profile(text);
917 let lang = prof_lat.language.clone();
918 let undecided = lang.is_none();
919 let english = lang.as_deref() == Some("en") || (undecided && !prof_lat.looks_non_english);
920 Analysis {
921 script: "latin".to_string(),
922 script_profile: prof,
923 language: lang,
924 is_english: english,
925 language_undecided: undecided,
926 diacritic_rate: round4(prof_lat.diacritic_rate),
927 non_latin_fraction: non_latin,
928 mixed_segment: None,
929 }
930}
931
932fn leaf_non_english(leaf: &str) -> Option<Analysis> {
941 let mut best: Option<Analysis> = None;
942 let mut best_n: i64 = -1;
943 for line in leaf.split('\n') {
944 let sample: String = line.chars().take(4000).collect();
945 if sample.trim().is_empty() || code_line_re().is_match(&sample) {
946 continue;
947 }
948 let det = analyse_text(&sample);
949 if det.is_english {
950 continue;
951 }
952 if matches!(det.language.as_deref(), Some(l) if l != "en") {
953 if named_prose_language(&sample).is_none() {
954 continue;
955 }
956 } else if det.script != "latin" && det.script != "unknown" {
957 let n_alpha = sample.chars().filter(|c| c.is_alphabetic()).count() as i64;
958 if non_latin_words(&sample).is_empty() || n_alpha < NON_LATIN_MIN_LETTERS {
959 continue;
960 }
961 } else if !(det.language_undecided
962 && det.diacritic_rate >= NON_EN_DIACRITIC_RATE
963 && word_re().find_iter(&sample).count() >= 4)
964 {
965 continue;
966 }
967 let n_alpha = sample.chars().filter(|c| c.is_alphabetic()).count() as i64;
968 if n_alpha > best_n {
969 best_n = n_alpha;
970 best = Some(det);
971 }
972 }
973 best
974}
975
976pub fn analyse(state: &Value) -> Analysis {
977 let mut result = analyse_text(&state_text(state, 4000));
978 if result.script == "latin" && result.is_english {
983 let mut leaves: Vec<String> = Vec::new();
984 iter_text(state, 0, &mut leaves);
985 if leaves.len() > 1 || leaves.iter().any(|l| l.contains('\n')) {
986 if let Some((seg_lang, seg)) = non_english_segment(state, 4000) {
987 result.language = Some(seg_lang);
988 result.is_english = false;
989 result.language_undecided = false;
990 result.mixed_segment = Some(seg);
991 }
992 }
993 }
994 if matches!(state, Value::String(_) | Value::Null) || !result.is_english {
998 return result;
999 }
1000 let mut leaves: Vec<String> = Vec::new();
1001 iter_text(state, 0, &mut leaves);
1002 let mut best: Option<Analysis> = None;
1003 let mut best_n: i64 = -1;
1004 for leaf in &leaves {
1005 let Some(det) = leaf_non_english(leaf) else {
1006 continue;
1007 };
1008 let capped: String = leaf.chars().take(4000).collect();
1009 let n_alpha = capped.chars().filter(|c| c.is_alphabetic()).count() as i64;
1010 if n_alpha > best_n {
1011 best_n = n_alpha;
1012 best = Some(det);
1013 }
1014 }
1015 if let Some(det) = best {
1016 result.language = det.language;
1017 result.is_english = false;
1018 result.language_undecided = det.language_undecided;
1019 }
1020 result
1021}
1022
1023pub fn is_english(state: &Value) -> bool {
1025 analyse(state).is_english
1026}
1027
1028#[cfg(test)]
1029mod tests {
1030 use super::*;
1031 use serde_json::json;
1032
1033 fn s(t: &str) -> Value {
1034 Value::String(t.to_string())
1035 }
1036
1037 #[test]
1038 fn detects_devanagari_as_non_latin() {
1039 assert_eq!(detect_script("मुझसे दो बार शुल्क लिया गया"), "devanagari");
1040 }
1041
1042 #[test]
1043 fn routes_english_latin_to_english() {
1044 let a = analyse(&s("Please refund the duplicate charge"));
1045 assert_eq!(a.script, "latin");
1046 assert!(a.is_english);
1047 assert_eq!(a.language.as_deref(), Some("en"));
1048 }
1049
1050 #[test]
1051 fn routes_german_latin_to_non_english() {
1052 assert!(!is_english(&s("Der Kunde wurde zweimal belastet")));
1053 }
1054
1055 #[test]
1056 fn detects_french_multilingual_signals() {
1057 let a = analyse(&s("Je voudrais un remboursement pour la commande"));
1058 assert!(!a.is_english);
1059 assert_eq!(a.language.as_deref(), Some("fr"));
1060 }
1061
1062 #[test]
1063 fn unknown_no_letters_is_english_and_undecided() {
1064 let a = analyse(&s("123 !!!"));
1065 assert_eq!(a.script, "unknown");
1066 assert!(a.is_english);
1067 assert!(a.language_undecided);
1068 }
1069
1070 #[test]
1071 fn cjk_han_is_not_english() {
1072 let a = analyse(&s("请退还重复的费用"));
1073 assert_eq!(a.script, "han");
1074 assert!(!a.is_english);
1075 }
1076
1077 #[test]
1078 fn hindi_devanagari_is_not_english() {
1079 let a = analyse(&s("मुझसे दो बार शुल्क लिया गया"));
1080 assert_eq!(a.script, "devanagari");
1081 assert!(!a.is_english);
1082 }
1083
1084 #[test]
1085 fn romanized_bangla_detected() {
1086 let a = analyse(&s("ami ekta refund chai amar order er jonno"));
1087 assert_eq!(a.script, "latin");
1088 assert_eq!(a.language.as_deref(), Some("bn"));
1089 assert!(!a.is_english);
1090 }
1091
1092 #[test]
1093 fn url_only_not_misdetected_as_foreign() {
1094 let a = analyse(&s("acme.com foo.com bar.com test.com"));
1096 assert_eq!(a.language, None);
1097 assert!(a.is_english);
1098 }
1099
1100 #[test]
1101 fn script_profile_latin_first_and_fractions() {
1102 let p = script_profile("abc देव");
1103 let keys: Vec<&String> = p.keys().collect();
1105 assert_eq!(keys[0], "latin");
1106 assert!(p.contains_key("devanagari"));
1107 let sum: f64 = p.values().sum();
1108 assert!((sum - 1.0).abs() < 1e-9);
1109 }
1110
1111 #[test]
1112 fn nested_state_is_flattened() {
1113 let state = json!({"a": "Please refund", "b": ["the", "duplicate", "charge"]});
1114 assert!(is_english(&state));
1115 }
1116
1117 #[test]
1118 fn round4_is_half_to_even() {
1119 assert_eq!(round_half_even(2.5), 2.0);
1120 assert_eq!(round_half_even(3.5), 4.0);
1121 }
1122
1123 #[test]
1124 fn mixed_field_makes_state_non_english() {
1125 let state = json!({
1128 "customer": "Eu preciso de ajuda com a minha conta pois fui cobrado duas vezes",
1129 "log": "The server returned an internal error and the request was retried three times before it finally failed on the second attempt with a timeout on the database connection pool that was exhausted"
1130 });
1131 let a = analyse(&state);
1132 assert_eq!(a.language.as_deref(), Some("pt"));
1133 assert!(!a.is_english);
1134 assert_eq!(
1135 a.mixed_segment.as_deref(),
1136 Some("Eu preciso de ajuda com a minha conta pois fui cobrado duas vezes")
1137 );
1138 }
1139
1140 #[test]
1141 fn mixed_segment_ignores_code_lines() {
1142 let a = analyse(&s(
1145 "Please refund the duplicate charge on my account.\nTraceback: os.path failed in round(el, 2) at non_english line 42 of the payment module during the retry",
1146 ));
1147 assert!(a.is_english);
1148 assert_eq!(a.language.as_deref(), Some("en"));
1149 assert_eq!(a.mixed_segment, None);
1150 }
1151
1152 #[test]
1153 fn single_line_never_reports_mixed_segment() {
1154 let a = analyse(&s("Please refund the duplicate charge on my account today"));
1156 assert_eq!(a.mixed_segment, None);
1157 }
1158
1159 #[test]
1160 fn one_loanword_keeps_plain_english() {
1161 let a = analyse(&s(
1164 "Please send José the invoice and the refund for his order today",
1165 ));
1166 assert_eq!(a.language.as_deref(), Some("en"));
1167 assert!(a.is_english);
1168 let a = analyse(&s(
1169 "I would like a refund for the résumé service I paid for twice this month",
1170 ));
1171 assert_eq!(a.language.as_deref(), Some("en"));
1172 assert!(a.is_english);
1173 }
1174
1175 #[test]
1176 fn loanword_rescue_is_bounded() {
1177 let a = analyse(&s(
1179 "We met at the café for a naïve chat about the résumé and the soirée plans",
1180 ));
1181 assert!(!a.is_english);
1182 let a = analyse(&s(
1184 "Jag har blivit debiterad två gånger och vill ha pengarna tillbaka nu",
1185 ));
1186 assert!(!a.is_english);
1187 }
1188
1189 #[test]
1190 fn per_leaf_detection_finds_hidden_non_english_field() {
1191 let big_en =
1195 "please help me with the account issue and the refund for the order ".repeat(80);
1196 let state = json!({
1197 "a_log": big_en,
1198 "z_customer": "Mein Konto wurde zweimal belastet und ich brauche dringend Hilfe",
1199 });
1200 let a = analyse(&state);
1201 assert_eq!(a.language.as_deref(), Some("de"));
1202 assert!(!a.is_english);
1203 assert_eq!(a.mixed_segment, None);
1204 }
1205}