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 NON_LATIN_FRACTION: f64 = 0.2;
73pub const NON_LATIN_MIN_FRACTION: f64 = 0.1;
74pub const NON_LATIN_MIN_LETTERS: i64 = 10;
75
76fn stop() -> &'static IndexMap<&'static str, HashSet<&'static str>> {
78 static S: OnceLock<IndexMap<&'static str, HashSet<&'static str>>> = OnceLock::new();
79 S.get_or_init(|| {
80 let mut m: IndexMap<&'static str, HashSet<&'static str>> = IndexMap::new();
81 m.insert(
82 "en",
83 [
84 "the", "and", "is", "are", "was", "were", "to", "of", "in", "for", "with", "that",
85 "this", "it", "you", "have", "has", "not", "but", "on", "at", "be", "as", "from",
86 "will", "can", "would", "there", "their", "what", "which", "please", "we", "i",
87 ]
88 .into_iter()
89 .collect(),
90 );
91 m.insert(
92 "fr",
93 [
94 "le", "la", "les", "des", "une", "est", "pour", "dans", "que", "qui", "avec",
95 "sur", "pas", "plus", "nous", "vous", "être", "cette", "mais", "sont", "ont",
96 "aux", "ce", "et", "du", "au", "ou", "je", "tu", "il", "elle", "ils", "elles",
97 "mon", "ton", "ma", "ta", "sa", "mes", "tes", "ses", "ces", "deux", "trois",
98 "très", "bien", "tout", "tous", "toute", "fait", "veux", "veut", "peux", "peut",
99 "dois", "doit", "merci", "bonjour", "jour", "jours", "mois", "fois", "quand",
100 "comment", "pourquoi", "alors", "donc",
101 ]
102 .into_iter()
103 .collect(),
104 );
105 m.insert(
106 "de",
107 [
108 "der", "die", "das", "und", "ist", "ein", "eine", "den", "dem", "nicht", "mit",
109 "für", "auf", "von", "zu", "sich", "auch", "werden", "wurde", "haben", "sind",
110 "oder", "aber", "ich", "wir", "mir", "mich", "dir", "dich", "uns", "mein", "meine",
111 "meinen", "meinem", "meiner", "diese", "dieser", "diesen", "dieses", "einen",
112 "einem", "einer", "wie", "wo", "wann", "welche", "im", "zum", "zur", "aus", "bei",
113 "nach", "noch", "bitte", "heute", "jetzt", "kann", "kannst", "habe", "gibt",
114 "wird",
115 "in", "was",
117 ]
118 .into_iter()
119 .collect(),
120 );
121 m.insert(
122 "es",
123 [
124 "el", "los", "las", "que", "por", "con", "para", "una", "es", "se", "del", "como",
125 "pero", "son", "está", "este", "esta", "todo", "más", "muy", "hay", "sus", "la",
126 "un", "y", "al", "lo", "le", "les", "su", "mi", "tu", "nos", "ni", "dos", "tres",
127 "fue", "fueron", "ser", "tiene", "tienen", "tengo", "puede", "pueden", "quiero",
128 "necesito", "hemos", "han", "sobre", "entre", "cuando", "donde", "porque",
129 "aunque", "también", "ya", "eso", "esto", "esa", "ese", "nada", "algo", "aquí",
130 "hoy", "gracias",
131 ]
132 .into_iter()
133 .collect(),
134 );
135 m.insert(
136 "pt",
137 [
138 "os", "as", "que", "em", "um", "uma", "para", "com", "não", "é", "se", "do", "da",
139 "dos", "das", "mas", "são", "está", "este", "esta", "muito", "pelo", "pela", "o",
140 "e", "na", "nas", "nos", "ao", "aos", "por", "foi", "era", "ser", "sou", "tem",
141 "tenho", "pode", "podem", "quero", "preciso", "eu", "meu", "minha", "seu", "sua",
142 "isso", "isto", "aqui", "ali", "como", "quando", "onde", "porque", "mais", "já",
143 "ainda", "agora", "hoje", "ontem", "dois", "três", "tudo", "nada", "obrigado",
144 "olá", "você", "vocês", "voce", "voces", "vc", "vcs", "nao", "sao", "ja", "até",
145 "tá", "pra", "gostaria", "obrigada", "também", "tambem", "estou", "estamos",
146 "meus", "minhas", "nosso", "nossa", "consigo", "cadê", "boa", "tarde", "noite",
147 "depois", "antes", "então", "entao", "ninguém", "ninguem", "alguém", "alguem",
148 "nenhum", "nenhuma", "estava", "ficou", "fiz", "deu",
149 ]
150 .into_iter()
151 .collect(),
152 );
153 m.insert(
154 "it",
155 [
156 "il", "lo", "gli", "che", "di", "per", "con", "non", "è", "si", "del", "della",
157 "sono", "questo", "questa", "anche", "come", "più", "sono", "nella", "alla", "la",
158 "le", "un", "uno", "una", "e", "ed", "o", "da", "su", "tra", "fra", "mi", "ci",
159 "ne", "ho", "hai", "ha", "abbiamo", "avete", "hanno", "era", "stato", "stata",
160 "devo", "deve", "devono", "voglio", "vorrei", "mio", "mia", "tuo", "sua", "quando",
161 "dove", "perche", "molto", "poco", "sempre", "mai", "già", "ancora", "adesso",
162 "oggi", "ieri", "grazie", "ciao", "scusa", "nel", "nell", "negli", "sul", "sulla",
163 "sulle", "dal", "dalla", "dallo", "dagli", "dei", "delle", "dello", "degli",
164 "agli", "alle", "col",
165 ]
166 .into_iter()
167 .collect(),
168 );
169 m.insert(
170 "nl",
171 [
172 "het", "een", "van", "is", "op", "te", "dat", "niet", "met", "voor", "zijn", "aan",
173 "door", "maar", "ook", "worden", "deze", "naar", "wordt",
174 ]
175 .into_iter()
176 .collect(),
177 );
178 m.insert(
179 "ro",
180 [
181 "și", "să", "este", "sunt", "care", "pentru", "din", "dar", "după", "până", "fără",
182 "ale", "lui", "în", "fost", "acum", "vreau", "trebuie", "foarte", "acest",
183 "această", "acesta", "aceasta", "mi", "ți", "vă", "nu",
184 ]
185 .into_iter()
186 .collect(),
187 );
188 m.insert(
189 "bn",
190 [
191 "ami",
192 "amar",
193 "amake",
194 "amra",
195 "amader",
196 "apni",
197 "apnar",
198 "apnake",
199 "apnara",
200 "tumi",
201 "tomar",
202 "tomake",
203 "tomra",
204 "tader",
205 "ota",
206 "eita",
207 "oita",
208 "ekta",
209 "ei",
210 "oi",
211 "ki",
212 "keno",
213 "kivabe",
214 "kibhabe",
215 "kothay",
216 "kokhon",
217 "kobe",
218 "koto",
219 "kintu",
220 "jodi",
221 "tahole",
222 "ar",
223 "theke",
224 "jonno",
225 "sathe",
226 "shathe",
227 "diye",
228 "niye",
229 "moddhe",
230 "kore",
231 "korte",
232 "korchi",
233 "korsi",
234 "korbo",
235 "korechi",
236 "koreche",
237 "korun",
238 "koren",
239 "korlam",
240 "hobe",
241 "hoyeche",
242 "hoise",
243 "hocche",
244 "hoyni",
245 "chai",
246 "chaina",
247 "lagbe",
248 "parchi",
249 "parbo",
250 "parchina",
251 "peyechi",
252 "paini",
253 "dite",
254 "dilam",
255 "diyechi",
256 "nai",
257 "khub",
258 "onek",
259 "ekhon",
260 "akhon",
261 "ekhono",
262 "abar",
263 "ekbar",
264 "duibar",
265 "ajke",
266 "kalke",
267 "taka",
268 "bhalo",
269 "valo",
270 "kharap",
271 "shomossa",
272 "somossa",
273 "dhonnobad",
274 "bhai",
275 "shob",
276 "keu",
277 "kichu",
278 "bolte",
279 "bolun",
280 "parben",
281 "asbe",
282 "jabe",
283 "pabo",
284 "ferot",
285 "dorkar",
286 "hoye",
287 "geche",
288 "gese",
289 ]
290 .into_iter()
291 .collect(),
292 );
293 m.insert(
294 "az",
295 [
296 "və", "ve", "bir", "bu", "üçün", "ucun", "ilə", "ile", "olan", "olub", "olmasa",
297 "var", "yox", "yoxdur", "mən", "sən", "biz", "siz", "onlar", "daha", "çox", "cox",
298 "hər", "nə", "kimi", "görə", "sonra", "əgər", "eger", "deyil", "lakin", "amma",
299 "ancaq", "artıq", "artiq", "də", "isə", "həm", "yalnız", "yalniz",
300 ]
301 .into_iter()
302 .collect(),
303 );
304 m
305 })
306}
307
308const DIACRITICS: &str = concat!(
310 "àâäãáåçéèêëíìîïñóòôöõøúùûüýÿßæœ", "ăâîșțşţ", "ąćęłńśźż", "čďěňřšťůž", "őű", "ğı", "āēģīķļņūž", "đ", "ə", );
320
321fn non_en_diacritics() -> &'static HashSet<char> {
322 static S: OnceLock<HashSet<char>> = OnceLock::new();
323 S.get_or_init(|| DIACRITICS.chars().collect())
324}
325
326fn shared_words() -> &'static HashSet<&'static str> {
328 static S: OnceLock<HashSet<&'static str>> = OnceLock::new();
329 S.get_or_init(|| {
330 let mut counts: HashMap<&'static str, usize> = HashMap::new();
331 for words in stop().values() {
332 for w in words {
333 *counts.entry(*w).or_insert(0) += 1;
334 }
335 }
336 counts
337 .into_iter()
338 .filter(|(_, n)| *n > 1)
339 .map(|(w, _)| w)
340 .collect()
341 })
342}
343
344fn word_re() -> &'static Regex {
345 static R: OnceLock<Regex> = OnceLock::new();
346 R.get_or_init(|| Regex::new(r"[^\W\d_]+").unwrap())
347}
348
349fn identifier_re() -> &'static Regex {
354 static R: OnceLock<Regex> = OnceLock::new();
355 R.get_or_init(|| Regex::new(r"[\w-]*(?:[.@][\w-]+)+").unwrap())
356}
357
358fn is_combining(ch: char) -> bool {
360 matches!(
361 get_general_category(ch),
362 GeneralCategory::NonspacingMark
363 | GeneralCategory::SpacingMark
364 | GeneralCategory::EnclosingMark
365 )
366}
367
368fn is_latin_detect(cp: u32) -> bool {
370 cp < 0x02B0
371 || (0x1E00..=0x1EFF).contains(&cp)
372 || (0xFF21..=0xFF3A).contains(&cp)
373 || (0xFF41..=0xFF5A).contains(&cp)
374}
375
376fn is_latin_script_of(cp: u32) -> bool {
378 cp < 0x0250
379 || (0x1E00..=0x1EFF).contains(&cp)
380 || (0xFF21..=0xFF3A).contains(&cp)
381 || (0xFF41..=0xFF5A).contains(&cp)
382}
383
384fn in_ranges(cp: u32, ranges: &[(u32, u32)]) -> bool {
385 ranges.iter().any(|&(lo, hi)| lo <= cp && cp <= hi)
386}
387
388fn round4(x: f64) -> f64 {
390 round_half_even(x * 10000.0) / 10000.0
391}
392
393fn round_half_even(x: f64) -> f64 {
395 let floor = x.floor();
396 let diff = x - floor;
397 if diff < 0.5 {
398 floor
399 } else if diff > 0.5 {
400 floor + 1.0
401 } else {
402 if (floor as i64) % 2 == 0 {
404 floor
405 } else {
406 floor + 1.0
407 }
408 }
409}
410
411fn iter_text(v: &Value, depth: usize, out: &mut Vec<String>) {
414 if depth > 6 {
415 return;
416 }
417 match v {
418 Value::Null => {}
419 Value::String(s) => out.push(s.clone()),
420 Value::Array(a) => {
421 for x in a {
422 iter_text(x, depth + 1, out);
423 }
424 }
425 Value::Object(m) => {
426 for x in m.values() {
427 iter_text(x, depth + 1, out);
428 }
429 }
430 _ => {}
431 }
432}
433
434pub fn state_text(state: &Value, max_chars: usize) -> String {
436 let mut leaves: Vec<String> = Vec::new();
437 iter_text(state, 0, &mut leaves);
438 let mut parts: Vec<String> = Vec::new();
441 let mut budget = max_chars as isize;
442 for leaf in leaves {
443 if budget <= 0 {
444 break;
445 }
446 let leaf_len = leaf.chars().count() as isize;
447 if leaf_len > budget {
448 parts.push(leaf.chars().take(budget as usize).collect());
449 break;
450 }
451 parts.push(leaf);
452 budget -= leaf_len + 1;
454 }
455 parts.join(" ").chars().take(max_chars).collect()
456}
457
458pub fn detect_script(text: &str) -> String {
462 let mut counts: IndexMap<&'static str, i64> = IndexMap::new();
463 let mut latin: i64 = 0;
464 for ch in text.chars() {
465 if !ch.is_alphabetic() {
466 continue;
467 }
468 let cp = ch as u32;
469 if is_latin_detect(cp) {
470 latin += 1;
471 continue;
472 }
473 let mut found: Option<&'static str> = None;
474 for (name, ranges) in SCRIPT_RANGES {
475 if in_ranges(cp, ranges) {
476 found = Some(name);
477 break;
478 }
479 }
480 let key = found.unwrap_or("other");
481 *counts.entry(key).or_insert(0) += 1;
482 }
483 counts.insert("latin", latin);
484 let total: i64 = counts.values().sum();
485 if total == 0 {
486 return "unknown".to_string();
487 }
488 let mut best: &str = "";
490 let mut best_n: i64 = i64::MIN;
491 for (k, v) in &counts {
492 if *v > best_n {
493 best_n = *v;
494 best = k;
495 }
496 }
497 best.to_string()
498}
499
500pub fn script_profile(text: &str) -> IndexMap<String, f64> {
502 let mut counts: IndexMap<&'static str, i64> = IndexMap::new();
503 counts.insert("latin", 0);
504 for ch in text.chars() {
505 if !ch.is_alphabetic() {
506 continue;
507 }
508 let cp = ch as u32;
509 if is_latin_detect(cp) {
510 *counts.get_mut("latin").unwrap() += 1;
511 continue;
512 }
513 let mut found: Option<&'static str> = None;
514 for (name, ranges) in SCRIPT_RANGES {
515 if in_ranges(cp, ranges) {
516 found = Some(name);
517 break;
518 }
519 }
520 let key = found.unwrap_or("other");
521 *counts.entry(key).or_insert(0) += 1;
522 }
523 let total: i64 = counts.values().sum();
524 let mut out: IndexMap<String, f64> = IndexMap::new();
525 if total == 0 {
526 return out;
527 }
528 for (k, v) in &counts {
529 if *v != 0 {
530 out.insert((*k).to_string(), *v as f64 / total as f64);
531 }
532 }
533 out
534}
535
536fn script_of(ch: char) -> Option<&'static str> {
537 let cp = ch as u32;
538 if is_latin_script_of(cp) {
539 return None;
540 }
541 for (name, ranges) in SCRIPT_RANGES {
542 if in_ranges(cp, ranges) {
543 return Some(name);
544 }
545 }
546 None
547}
548
549fn non_latin_words(text: &str) -> Vec<String> {
551 let mut runs: Vec<String> = Vec::new();
552 let mut cur = String::new();
553 let mut script: Option<&'static str> = None;
554 for ch in text.chars() {
555 if is_combining(ch) {
556 continue;
557 }
558 let s = script_of(ch);
559 if s.is_some() && s == script {
560 cur.push(ch);
561 continue;
562 }
563 if !cur.is_empty() {
564 runs.push(std::mem::take(&mut cur));
565 }
566 match s {
567 Some(_) => {
568 cur = ch.to_string();
569 script = s;
570 }
571 None => {
572 cur = String::new();
573 script = None;
574 }
575 }
576 }
577 if !cur.is_empty() {
578 runs.push(cur);
579 }
580 runs.into_iter()
581 .filter(|w| {
582 w.chars().count() >= 2 && !w.chars().next().map(|c| c.is_uppercase()).unwrap_or(false)
583 })
584 .collect()
585}
586
587#[derive(Debug, Clone)]
591pub struct LatinProfile {
592 pub language: Option<String>,
593 pub english_hits: usize,
594 pub diacritic_rate: f64,
595 pub looks_non_english: bool,
596}
597
598pub fn latin_profile(text: &str) -> LatinProfile {
599 let stripped = identifier_re().replace_all(text, " ");
601 let normalized = stripped.replace('\u{0130}', "i").to_lowercase();
602 let words: Vec<&str> = word_re()
603 .find_iter(&normalized)
604 .map(|m| m.as_str())
605 .collect();
606
607 let lowered = text.to_lowercase();
608 let diac_set = non_en_diacritics();
609 let diac = lowered.chars().filter(|ch| diac_set.contains(ch)).count();
610 let len = lowered.chars().count();
611 let diac_rate = diac as f64 / (std::cmp::max(1, len) as f64);
612 let non_english = diac_rate >= NON_EN_DIACRITIC_RATE;
613
614 if words.len() < 4 {
615 return LatinProfile {
616 language: None,
617 english_hits: 0,
618 diacritic_rate: diac_rate,
619 looks_non_english: non_english,
620 };
621 }
622
623 let stop = stop();
624 let shared = shared_words();
625
626 let mut scores: IndexMap<&'static str, i64> = IndexMap::new();
628 for (lg, sw) in stop {
629 let s = words.iter().filter(|w| sw.contains(*w)).count() as i64;
630 scores.insert(lg, s);
631 }
632 let en = scores.get("en").copied().unwrap_or(0);
633
634 let word_set: HashSet<&str> = words.iter().copied().collect();
635
636 let mut best_lg: Option<&'static str> = None;
638 let mut best: i64 = 0;
639 for (lg, sw) in stop {
640 if *lg == "en" {
641 continue;
642 }
643 let has_evidence = word_set
644 .iter()
645 .any(|w| sw.contains(*w) && !shared.contains(*w));
646 if !has_evidence {
647 continue;
648 }
649 let s = *scores.get(lg).unwrap();
650 if s > best {
651 best = s;
652 best_lg = Some(lg);
653 }
654 }
655
656 let mut language: Option<String> = None;
657 if let Some(bl) = best_lg {
658 if best >= std::cmp::max(2, en + 2) || (non_english && best >= std::cmp::max(2, en)) {
662 language = Some(bl.to_string());
663 } else if en > 0 && !non_english {
664 language = Some("en".to_string());
665 }
666 } else if en > 0 && !non_english {
667 language = Some("en".to_string());
668 }
669
670 LatinProfile {
671 language,
672 english_hits: en as usize,
673 diacritic_rate: diac_rate,
674 looks_non_english: non_english,
675 }
676}
677
678pub fn guess_latin_language(text: &str) -> Option<String> {
680 latin_profile(text).language
681}
682
683fn code_line_re() -> &'static Regex {
689 static R: OnceLock<Regex> = OnceLock::new();
690 R.get_or_init(|| Regex::new(r"[=;{}\[\]]|\w\(").unwrap())
691}
692
693fn joined_re() -> &'static Regex {
697 static R: OnceLock<Regex> = OnceLock::new();
698 R.get_or_init(|| Regex::new(r"[^\W_][._/\\][^\W_]").unwrap())
699}
700
701fn letter_run_re() -> &'static Regex {
705 static R: OnceLock<Regex> = OnceLock::new();
706 R.get_or_init(|| Regex::new(r"[^\W\d_]{2,}").unwrap())
707}
708
709fn is_all_upper(s: &str) -> bool {
712 let mut has_cased = false;
713 for c in s.chars() {
714 if c.is_lowercase() {
715 return false;
716 }
717 if c.is_uppercase() {
718 has_cased = true;
719 }
720 }
721 has_cased
722}
723
724fn non_english_segment(state: &Value, max_chars: usize) -> Option<(String, String)> {
732 let mut leaves: Vec<String> = Vec::new();
733 iter_text(state, 0, &mut leaves);
734 let stop = stop();
735 let mut seen = 0usize;
736 for leaf in &leaves {
737 for seg in leaf.split('\n') {
738 if seen >= max_chars {
739 return None;
740 }
741 let seg: String = seg.chars().take(max_chars - seen).collect();
742 seen += seg.chars().count();
743 if code_line_re().is_match(&seg) {
744 continue;
745 }
746 let joined = joined_re();
747 let prose = seg
748 .split_whitespace()
749 .filter(|tok| !joined.is_match(tok))
750 .collect::<Vec<_>>()
751 .join(" ");
752 let prose = if prose.chars().any(|c| c.is_lowercase()) {
753 letter_run_re()
754 .replace_all(&prose, |caps: ®ex::Captures| {
755 let m = &caps[0];
756 if is_all_upper(m) {
757 " ".to_string()
758 } else {
759 m.to_string()
760 }
761 })
762 .into_owned()
763 } else {
764 prose
765 };
766 let tokens: Vec<&str> = word_re().find_iter(&prose).map(|m| m.as_str()).collect();
767 if tokens.len() < 4 {
768 continue;
769 }
770 let lang = match latin_profile(&prose).language {
771 Some(l) if l != "en" => l,
772 _ => continue,
773 };
774 if let Some(sw) = stop.get(lang.as_str()) {
775 let lowered: HashSet<String> = tokens.iter().map(|w| w.to_lowercase()).collect();
776 let hits = lowered.iter().filter(|w| sw.contains(w.as_str())).count();
777 if hits >= 2 {
778 return Some((lang, seg.trim().to_string()));
779 }
780 }
781 }
782 }
783 None
784}
785
786#[derive(Debug, Clone)]
790pub struct Analysis {
791 pub script: String,
792 pub script_profile: IndexMap<String, f64>,
793 pub language: Option<String>,
794 pub is_english: bool,
795 pub language_undecided: bool,
796 pub diacritic_rate: f64,
797 pub non_latin_fraction: f64,
798 pub mixed_segment: Option<String>,
800}
801
802pub fn analyse(state: &Value) -> Analysis {
803 let text = state_text(state, 4000);
804 let prof = script_profile(&text);
805 let mut script = detect_script(&text);
806
807 let non_latin = if !prof.is_empty() {
808 round4(1.0 - prof.get("latin").copied().unwrap_or(0.0))
809 } else {
810 0.0
811 };
812 let sum_alpha = text.chars().filter(|c| c.is_alphabetic()).count() as f64;
813 let n_non_latin = round_half_even(non_latin * sum_alpha) as i64;
814
815 if script == "latin"
816 && !non_latin_words(&text).is_empty()
817 && (non_latin >= NON_LATIN_FRACTION
818 || (non_latin >= NON_LATIN_MIN_FRACTION && n_non_latin >= NON_LATIN_MIN_LETTERS))
819 {
820 let mut best: Option<&str> = None;
822 let mut best_v = f64::NEG_INFINITY;
823 for (k, v) in &prof {
824 if k == "latin" {
825 continue;
826 }
827 if *v > best_v {
828 best_v = *v;
829 best = Some(k);
830 }
831 }
832 if let Some(b) = best {
833 script = b.to_string();
834 }
835 }
836
837 if script == "unknown" {
838 return Analysis {
839 script: "unknown".to_string(),
840 script_profile: prof,
841 language: None,
842 is_english: true,
843 language_undecided: true,
844 diacritic_rate: 0.0,
845 non_latin_fraction: 0.0,
846 mixed_segment: None,
847 };
848 }
849 if script != "latin" {
850 return Analysis {
851 script,
852 script_profile: prof,
853 language: None,
854 is_english: false,
855 language_undecided: true,
856 diacritic_rate: 0.0,
857 non_latin_fraction: non_latin,
858 mixed_segment: None,
859 };
860 }
861
862 let prof_lat = latin_profile(&text);
863 let mut lang = prof_lat.language.clone();
864 let mut undecided = lang.is_none();
865 let mut english = lang.as_deref() == Some("en") || (undecided && !prof_lat.looks_non_english);
866 let mut mixed: Option<String> = None;
871 let mut leaves: Vec<String> = Vec::new();
872 iter_text(state, 0, &mut leaves);
873 if english && (leaves.len() > 1 || leaves.iter().any(|l| l.contains('\n'))) {
874 if let Some((seg_lang, seg)) = non_english_segment(state, 4000) {
875 lang = Some(seg_lang);
876 mixed = Some(seg);
877 english = false;
878 undecided = false;
879 }
880 }
881 Analysis {
882 script: "latin".to_string(),
883 script_profile: prof,
884 language: lang,
885 is_english: english,
886 language_undecided: undecided,
887 diacritic_rate: round4(prof_lat.diacritic_rate),
888 non_latin_fraction: non_latin,
889 mixed_segment: mixed,
890 }
891}
892
893pub fn is_english(state: &Value) -> bool {
895 analyse(state).is_english
896}
897
898#[cfg(test)]
899mod tests {
900 use super::*;
901 use serde_json::json;
902
903 fn s(t: &str) -> Value {
904 Value::String(t.to_string())
905 }
906
907 #[test]
908 fn detects_devanagari_as_non_latin() {
909 assert_eq!(detect_script("मुझसे दो बार शुल्क लिया गया"), "devanagari");
910 }
911
912 #[test]
913 fn routes_english_latin_to_english() {
914 let a = analyse(&s("Please refund the duplicate charge"));
915 assert_eq!(a.script, "latin");
916 assert!(a.is_english);
917 assert_eq!(a.language.as_deref(), Some("en"));
918 }
919
920 #[test]
921 fn routes_german_latin_to_non_english() {
922 assert!(!is_english(&s("Der Kunde wurde zweimal belastet")));
923 }
924
925 #[test]
926 fn detects_french_multilingual_signals() {
927 let a = analyse(&s("Je voudrais un remboursement pour la commande"));
928 assert!(!a.is_english);
929 assert_eq!(a.language.as_deref(), Some("fr"));
930 }
931
932 #[test]
933 fn unknown_no_letters_is_english_and_undecided() {
934 let a = analyse(&s("123 !!!"));
935 assert_eq!(a.script, "unknown");
936 assert!(a.is_english);
937 assert!(a.language_undecided);
938 }
939
940 #[test]
941 fn cjk_han_is_not_english() {
942 let a = analyse(&s("请退还重复的费用"));
943 assert_eq!(a.script, "han");
944 assert!(!a.is_english);
945 }
946
947 #[test]
948 fn hindi_devanagari_is_not_english() {
949 let a = analyse(&s("मुझसे दो बार शुल्क लिया गया"));
950 assert_eq!(a.script, "devanagari");
951 assert!(!a.is_english);
952 }
953
954 #[test]
955 fn romanized_bangla_detected() {
956 let a = analyse(&s("ami ekta refund chai amar order er jonno"));
957 assert_eq!(a.script, "latin");
958 assert_eq!(a.language.as_deref(), Some("bn"));
959 assert!(!a.is_english);
960 }
961
962 #[test]
963 fn url_only_not_misdetected_as_foreign() {
964 let a = analyse(&s("acme.com foo.com bar.com test.com"));
966 assert_eq!(a.language, None);
967 assert!(a.is_english);
968 }
969
970 #[test]
971 fn script_profile_latin_first_and_fractions() {
972 let p = script_profile("abc देव");
973 let keys: Vec<&String> = p.keys().collect();
975 assert_eq!(keys[0], "latin");
976 assert!(p.contains_key("devanagari"));
977 let sum: f64 = p.values().sum();
978 assert!((sum - 1.0).abs() < 1e-9);
979 }
980
981 #[test]
982 fn nested_state_is_flattened() {
983 let state = json!({"a": "Please refund", "b": ["the", "duplicate", "charge"]});
984 assert!(is_english(&state));
985 }
986
987 #[test]
988 fn round4_is_half_to_even() {
989 assert_eq!(round_half_even(2.5), 2.0);
990 assert_eq!(round_half_even(3.5), 4.0);
991 }
992
993 #[test]
994 fn mixed_field_makes_state_non_english() {
995 let state = json!({
998 "customer": "Eu preciso de ajuda com a minha conta pois fui cobrado duas vezes",
999 "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"
1000 });
1001 let a = analyse(&state);
1002 assert_eq!(a.language.as_deref(), Some("pt"));
1003 assert!(!a.is_english);
1004 assert_eq!(
1005 a.mixed_segment.as_deref(),
1006 Some("Eu preciso de ajuda com a minha conta pois fui cobrado duas vezes")
1007 );
1008 }
1009
1010 #[test]
1011 fn mixed_segment_ignores_code_lines() {
1012 let a = analyse(&s(
1015 "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",
1016 ));
1017 assert!(a.is_english);
1018 assert_eq!(a.language.as_deref(), Some("en"));
1019 assert_eq!(a.mixed_segment, None);
1020 }
1021
1022 #[test]
1023 fn single_line_never_reports_mixed_segment() {
1024 let a = analyse(&s("Please refund the duplicate charge on my account today"));
1026 assert_eq!(a.mixed_segment, None);
1027 }
1028}