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 if line.chars().count() < 7 {
950 continue;
951 }
952 let sample: String = line.chars().take(4000).collect();
953 if sample.trim().is_empty() || code_line_re().is_match(&sample) {
954 continue;
955 }
956 let det = analyse_text(&sample);
957 if det.is_english {
958 continue;
959 }
960 if matches!(det.language.as_deref(), Some(l) if l != "en") {
961 if named_prose_language(&sample).is_none() {
962 continue;
963 }
964 } else if det.script != "latin" && det.script != "unknown" {
965 let n_alpha = sample.chars().filter(|c| c.is_alphabetic()).count() as i64;
966 if non_latin_words(&sample).is_empty() || n_alpha < NON_LATIN_MIN_LETTERS {
967 continue;
968 }
969 } else if !(det.language_undecided
970 && det.diacritic_rate >= NON_EN_DIACRITIC_RATE
971 && word_re().find_iter(&sample).count() >= 4)
972 {
973 continue;
974 }
975 let n_alpha = sample.chars().filter(|c| c.is_alphabetic()).count() as i64;
976 if n_alpha > best_n {
977 best_n = n_alpha;
978 best = Some(det);
979 }
980 }
981 best
982}
983
984pub fn analyse(state: &Value) -> Analysis {
985 let mut result = analyse_text(&state_text(state, 4000));
986 if result.script == "latin" && result.is_english {
991 let mut leaves: Vec<String> = Vec::new();
992 iter_text(state, 0, &mut leaves);
993 if leaves.len() > 1 || leaves.iter().any(|l| l.contains('\n')) {
994 if let Some((seg_lang, seg)) = non_english_segment(state, 4000) {
995 result.language = Some(seg_lang);
996 result.is_english = false;
997 result.language_undecided = false;
998 result.mixed_segment = Some(seg);
999 }
1000 }
1001 }
1002 if matches!(state, Value::String(_) | Value::Null) || !result.is_english {
1006 return result;
1007 }
1008 let mut leaves: Vec<String> = Vec::new();
1009 iter_text(state, 0, &mut leaves);
1010 let mut best: Option<Analysis> = None;
1011 let mut best_n: i64 = -1;
1012 for leaf in &leaves {
1013 let Some(det) = leaf_non_english(leaf) else {
1014 continue;
1015 };
1016 let capped: String = leaf.chars().take(4000).collect();
1017 let n_alpha = capped.chars().filter(|c| c.is_alphabetic()).count() as i64;
1018 if n_alpha > best_n {
1019 best_n = n_alpha;
1020 best = Some(det);
1021 }
1022 }
1023 if let Some(det) = best {
1024 result.language = det.language;
1025 result.is_english = false;
1026 result.language_undecided = det.language_undecided;
1027 }
1028 result
1029}
1030
1031pub fn is_english(state: &Value) -> bool {
1033 analyse(state).is_english
1034}
1035
1036#[cfg(test)]
1037mod tests {
1038 use super::*;
1039 use serde_json::json;
1040
1041 fn s(t: &str) -> Value {
1042 Value::String(t.to_string())
1043 }
1044
1045 #[test]
1046 fn detects_devanagari_as_non_latin() {
1047 assert_eq!(detect_script("मुझसे दो बार शुल्क लिया गया"), "devanagari");
1048 }
1049
1050 #[test]
1051 fn routes_english_latin_to_english() {
1052 let a = analyse(&s("Please refund the duplicate charge"));
1053 assert_eq!(a.script, "latin");
1054 assert!(a.is_english);
1055 assert_eq!(a.language.as_deref(), Some("en"));
1056 }
1057
1058 #[test]
1059 fn routes_german_latin_to_non_english() {
1060 assert!(!is_english(&s("Der Kunde wurde zweimal belastet")));
1061 }
1062
1063 #[test]
1064 fn detects_french_multilingual_signals() {
1065 let a = analyse(&s("Je voudrais un remboursement pour la commande"));
1066 assert!(!a.is_english);
1067 assert_eq!(a.language.as_deref(), Some("fr"));
1068 }
1069
1070 #[test]
1071 fn unknown_no_letters_is_english_and_undecided() {
1072 let a = analyse(&s("123 !!!"));
1073 assert_eq!(a.script, "unknown");
1074 assert!(a.is_english);
1075 assert!(a.language_undecided);
1076 }
1077
1078 #[test]
1079 fn cjk_han_is_not_english() {
1080 let a = analyse(&s("请退还重复的费用"));
1081 assert_eq!(a.script, "han");
1082 assert!(!a.is_english);
1083 }
1084
1085 #[test]
1086 fn hindi_devanagari_is_not_english() {
1087 let a = analyse(&s("मुझसे दो बार शुल्क लिया गया"));
1088 assert_eq!(a.script, "devanagari");
1089 assert!(!a.is_english);
1090 }
1091
1092 #[test]
1093 fn romanized_bangla_detected() {
1094 let a = analyse(&s("ami ekta refund chai amar order er jonno"));
1095 assert_eq!(a.script, "latin");
1096 assert_eq!(a.language.as_deref(), Some("bn"));
1097 assert!(!a.is_english);
1098 }
1099
1100 #[test]
1101 fn url_only_not_misdetected_as_foreign() {
1102 let a = analyse(&s("acme.com foo.com bar.com test.com"));
1104 assert_eq!(a.language, None);
1105 assert!(a.is_english);
1106 }
1107
1108 #[test]
1109 fn script_profile_latin_first_and_fractions() {
1110 let p = script_profile("abc देव");
1111 let keys: Vec<&String> = p.keys().collect();
1113 assert_eq!(keys[0], "latin");
1114 assert!(p.contains_key("devanagari"));
1115 let sum: f64 = p.values().sum();
1116 assert!((sum - 1.0).abs() < 1e-9);
1117 }
1118
1119 #[test]
1120 fn nested_state_is_flattened() {
1121 let state = json!({"a": "Please refund", "b": ["the", "duplicate", "charge"]});
1122 assert!(is_english(&state));
1123 }
1124
1125 #[test]
1126 fn round4_is_half_to_even() {
1127 assert_eq!(round_half_even(2.5), 2.0);
1128 assert_eq!(round_half_even(3.5), 4.0);
1129 }
1130
1131 #[test]
1132 fn mixed_field_makes_state_non_english() {
1133 let state = json!({
1136 "customer": "Eu preciso de ajuda com a minha conta pois fui cobrado duas vezes",
1137 "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"
1138 });
1139 let a = analyse(&state);
1140 assert_eq!(a.language.as_deref(), Some("pt"));
1141 assert!(!a.is_english);
1142 assert_eq!(
1143 a.mixed_segment.as_deref(),
1144 Some("Eu preciso de ajuda com a minha conta pois fui cobrado duas vezes")
1145 );
1146 }
1147
1148 #[test]
1149 fn mixed_segment_ignores_code_lines() {
1150 let a = analyse(&s(
1153 "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",
1154 ));
1155 assert!(a.is_english);
1156 assert_eq!(a.language.as_deref(), Some("en"));
1157 assert_eq!(a.mixed_segment, None);
1158 }
1159
1160 #[test]
1161 fn single_line_never_reports_mixed_segment() {
1162 let a = analyse(&s("Please refund the duplicate charge on my account today"));
1164 assert_eq!(a.mixed_segment, None);
1165 }
1166
1167 #[test]
1168 fn one_loanword_keeps_plain_english() {
1169 let a = analyse(&s(
1172 "Please send José the invoice and the refund for his order today",
1173 ));
1174 assert_eq!(a.language.as_deref(), Some("en"));
1175 assert!(a.is_english);
1176 let a = analyse(&s(
1177 "I would like a refund for the résumé service I paid for twice this month",
1178 ));
1179 assert_eq!(a.language.as_deref(), Some("en"));
1180 assert!(a.is_english);
1181 }
1182
1183 #[test]
1184 fn loanword_rescue_is_bounded() {
1185 let a = analyse(&s(
1187 "We met at the café for a naïve chat about the résumé and the soirée plans",
1188 ));
1189 assert!(!a.is_english);
1190 let a = analyse(&s(
1192 "Jag har blivit debiterad två gånger och vill ha pengarna tillbaka nu",
1193 ));
1194 assert!(!a.is_english);
1195 }
1196
1197 #[test]
1198 fn per_leaf_detection_finds_hidden_non_english_field() {
1199 let big_en =
1203 "please help me with the account issue and the refund for the order ".repeat(80);
1204 let state = json!({
1205 "a_log": big_en,
1206 "z_customer": "Mein Konto wurde zweimal belastet und ich brauche dringend Hilfe",
1207 });
1208 let a = analyse(&state);
1209 assert_eq!(a.language.as_deref(), Some("de"));
1210 assert!(!a.is_english);
1211 assert_eq!(a.mixed_segment, None);
1212 }
1213}