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 en_collision_words() -> &'static HashSet<&'static str> {
371 static S: OnceLock<HashSet<&'static str>> = OnceLock::new();
372 S.get_or_init(|| {
373 const CANDIDATES: [&str; 8] = ["come", "son", "do", "care", "todo", "im", "per", "plus"];
374 let mut non_en: HashSet<&'static str> = HashSet::new();
375 for (lg, sw) in stop() {
376 if *lg == "en" {
377 continue;
378 }
379 non_en.extend(sw.iter().copied());
380 }
381 CANDIDATES
382 .into_iter()
383 .filter(|w| non_en.contains(w))
384 .collect()
385 })
386}
387
388fn english_rescued_by_words(words: &[&str], diac_rate: f64) -> bool {
397 if diac_rate >= ENGLISH_RESCUE_DIACRITIC_RATE {
398 return false;
399 }
400 let word_set: HashSet<&str> = words.iter().copied().collect();
401 let en_only = en_only_words();
402 if word_set.iter().filter(|w| en_only.contains(*w)).count() < 2 {
403 return false;
404 }
405 let diac = non_en_diacritics();
406 word_set
407 .iter()
408 .filter(|w| w.chars().any(|ch| diac.contains(&ch)))
409 .count()
410 <= 1
411}
412
413fn word_re() -> &'static Regex {
414 static R: OnceLock<Regex> = OnceLock::new();
415 R.get_or_init(|| Regex::new(r"[^\W\d_]+").unwrap())
416}
417
418fn identifier_re() -> &'static Regex {
423 static R: OnceLock<Regex> = OnceLock::new();
424 R.get_or_init(|| Regex::new(r"[\w-]*(?:[.@][\w-]+)+").unwrap())
425}
426
427fn is_combining(ch: char) -> bool {
429 matches!(
430 get_general_category(ch),
431 GeneralCategory::NonspacingMark
432 | GeneralCategory::SpacingMark
433 | GeneralCategory::EnclosingMark
434 )
435}
436
437fn is_latin_detect(cp: u32) -> bool {
439 cp < 0x02B0
440 || (0x1E00..=0x1EFF).contains(&cp)
441 || (0xFF21..=0xFF3A).contains(&cp)
442 || (0xFF41..=0xFF5A).contains(&cp)
443}
444
445fn is_latin_script_of(cp: u32) -> bool {
447 cp < 0x0250
448 || (0x1E00..=0x1EFF).contains(&cp)
449 || (0xFF21..=0xFF3A).contains(&cp)
450 || (0xFF41..=0xFF5A).contains(&cp)
451}
452
453fn in_ranges(cp: u32, ranges: &[(u32, u32)]) -> bool {
454 ranges.iter().any(|&(lo, hi)| lo <= cp && cp <= hi)
455}
456
457fn round4(x: f64) -> f64 {
459 round_half_even(x * 10000.0) / 10000.0
460}
461
462fn round_half_even(x: f64) -> f64 {
464 let floor = x.floor();
465 let diff = x - floor;
466 if diff < 0.5 {
467 floor
468 } else if diff > 0.5 {
469 floor + 1.0
470 } else {
471 if (floor as i64) % 2 == 0 {
473 floor
474 } else {
475 floor + 1.0
476 }
477 }
478}
479
480fn iter_text(v: &Value, depth: usize, out: &mut Vec<String>) {
483 if depth > 6 {
484 return;
485 }
486 match v {
487 Value::Null => {}
488 Value::String(s) => out.push(s.clone()),
489 Value::Array(a) => {
490 for x in a {
491 iter_text(x, depth + 1, out);
492 }
493 }
494 Value::Object(m) => {
495 for x in m.values() {
496 iter_text(x, depth + 1, out);
497 }
498 }
499 _ => {}
500 }
501}
502
503pub fn state_text(state: &Value, max_chars: usize) -> String {
505 let mut leaves: Vec<String> = Vec::new();
506 iter_text(state, 0, &mut leaves);
507 let mut parts: Vec<String> = Vec::new();
510 let mut budget = max_chars as isize;
511 for leaf in leaves {
512 if budget <= 0 {
513 break;
514 }
515 let leaf_len = leaf.chars().count() as isize;
516 if leaf_len > budget {
517 parts.push(leaf.chars().take(budget as usize).collect());
518 break;
519 }
520 parts.push(leaf);
521 budget -= leaf_len + 1;
523 }
524 parts.join(" ").chars().take(max_chars).collect()
525}
526
527pub fn detect_script(text: &str) -> String {
531 let mut counts: IndexMap<&'static str, i64> = IndexMap::new();
532 let mut latin: i64 = 0;
533 for ch in text.chars() {
534 if !ch.is_alphabetic() {
535 continue;
536 }
537 let cp = ch as u32;
538 if is_latin_detect(cp) {
539 latin += 1;
540 continue;
541 }
542 let mut found: Option<&'static str> = None;
543 for (name, ranges) in SCRIPT_RANGES {
544 if in_ranges(cp, ranges) {
545 found = Some(name);
546 break;
547 }
548 }
549 let key = found.unwrap_or("other");
550 *counts.entry(key).or_insert(0) += 1;
551 }
552 counts.insert("latin", latin);
553 let total: i64 = counts.values().sum();
554 if total == 0 {
555 return "unknown".to_string();
556 }
557 let mut best: &str = "";
559 let mut best_n: i64 = i64::MIN;
560 for (k, v) in &counts {
561 if *v > best_n {
562 best_n = *v;
563 best = k;
564 }
565 }
566 best.to_string()
567}
568
569pub fn script_profile(text: &str) -> IndexMap<String, f64> {
571 let mut counts: IndexMap<&'static str, i64> = IndexMap::new();
572 counts.insert("latin", 0);
573 for ch in text.chars() {
574 if !ch.is_alphabetic() {
575 continue;
576 }
577 let cp = ch as u32;
578 if is_latin_detect(cp) {
579 *counts.get_mut("latin").unwrap() += 1;
580 continue;
581 }
582 let mut found: Option<&'static str> = None;
583 for (name, ranges) in SCRIPT_RANGES {
584 if in_ranges(cp, ranges) {
585 found = Some(name);
586 break;
587 }
588 }
589 let key = found.unwrap_or("other");
590 *counts.entry(key).or_insert(0) += 1;
591 }
592 let total: i64 = counts.values().sum();
593 let mut out: IndexMap<String, f64> = IndexMap::new();
594 if total == 0 {
595 return out;
596 }
597 for (k, v) in &counts {
598 if *v != 0 {
599 out.insert((*k).to_string(), *v as f64 / total as f64);
600 }
601 }
602 out
603}
604
605fn script_of(ch: char) -> Option<&'static str> {
606 let cp = ch as u32;
607 if is_latin_script_of(cp) {
608 return None;
609 }
610 for (name, ranges) in SCRIPT_RANGES {
611 if in_ranges(cp, ranges) {
612 return Some(name);
613 }
614 }
615 None
616}
617
618fn non_latin_words(text: &str) -> Vec<String> {
620 let mut runs: Vec<String> = Vec::new();
621 let mut cur = String::new();
622 let mut script: Option<&'static str> = None;
623 for ch in text.chars() {
624 if is_combining(ch) {
625 continue;
626 }
627 let s = script_of(ch);
628 if s.is_some() && s == script {
629 cur.push(ch);
630 continue;
631 }
632 if !cur.is_empty() {
633 runs.push(std::mem::take(&mut cur));
634 }
635 match s {
636 Some(_) => {
637 cur = ch.to_string();
638 script = s;
639 }
640 None => {
641 cur = String::new();
642 script = None;
643 }
644 }
645 }
646 if !cur.is_empty() {
647 runs.push(cur);
648 }
649 runs.into_iter()
650 .filter(|w| {
651 w.chars().count() >= 2 && !w.chars().next().map(|c| c.is_uppercase()).unwrap_or(false)
652 })
653 .collect()
654}
655
656#[derive(Debug, Clone)]
660pub struct LatinProfile {
661 pub language: Option<String>,
662 pub english_hits: usize,
663 pub diacritic_rate: f64,
664 pub looks_non_english: bool,
665}
666
667pub fn latin_profile(text: &str) -> LatinProfile {
668 let stripped = identifier_re().replace_all(text, " ");
670 let normalized = stripped.replace('\u{0130}', "i").to_lowercase();
671 let words: Vec<&str> = word_re()
672 .find_iter(&normalized)
673 .map(|m| m.as_str())
674 .collect();
675
676 let lowered = text.to_lowercase();
677 let diac_set = non_en_diacritics();
678 let diac = lowered.chars().filter(|ch| diac_set.contains(ch)).count();
679 let len = lowered.chars().count();
680 let diac_rate = diac as f64 / (std::cmp::max(1, len) as f64);
681 let non_english = diac_rate >= NON_EN_DIACRITIC_RATE;
682
683 if words.len() < 4 {
684 return LatinProfile {
685 language: None,
686 english_hits: 0,
687 diacritic_rate: diac_rate,
688 looks_non_english: non_english,
689 };
690 }
691
692 let stop = stop();
693 let shared = shared_words();
694
695 let mut counts: HashMap<&str, i64> = HashMap::new();
698 for w in &words {
699 *counts.entry(*w).or_insert(0) += 1;
700 }
701 let collision = en_collision_words();
702 let mut scores: IndexMap<&'static str, i64> = IndexMap::new();
703 for (lg, sw) in stop {
704 let s: i64 = counts
705 .iter()
706 .filter(|(w, _)| sw.contains(**w))
707 .map(|(w, n)| if collision.contains(*w) { 1 } else { *n })
708 .sum();
709 scores.insert(lg, s);
710 }
711 let en = scores.get("en").copied().unwrap_or(0);
712
713 let word_set: HashSet<&str> = words.iter().copied().collect();
714
715 let mut best_lg: Option<&'static str> = None;
717 let mut best: i64 = 0;
718 for (lg, sw) in stop {
719 if *lg == "en" {
720 continue;
721 }
722 let has_evidence = word_set
723 .iter()
724 .any(|w| sw.contains(*w) && !shared.contains(*w));
725 if !has_evidence {
726 continue;
727 }
728 let s = *scores.get(lg).unwrap();
729 if s > best {
730 best = s;
731 best_lg = Some(lg);
732 }
733 }
734
735 let mut language: Option<String> = None;
736 if let Some(bl) = best_lg {
737 if best >= std::cmp::max(2, en + 2) || (non_english && best >= std::cmp::max(2, en)) {
741 language = Some(bl.to_string());
742 } else if en > 0 && (!non_english || english_rescued_by_words(&words, diac_rate)) {
743 language = Some("en".to_string());
744 }
745 } else if en > 0 && (!non_english || english_rescued_by_words(&words, diac_rate)) {
746 language = Some("en".to_string());
747 }
748
749 LatinProfile {
750 language,
751 english_hits: en as usize,
752 diacritic_rate: diac_rate,
753 looks_non_english: non_english,
754 }
755}
756
757pub fn guess_latin_language(text: &str) -> Option<String> {
759 latin_profile(text).language
760}
761
762fn code_line_re() -> &'static Regex {
768 static R: OnceLock<Regex> = OnceLock::new();
769 R.get_or_init(|| Regex::new(r"[=;{}\[\]]|\w\(").unwrap())
770}
771
772fn joined_re() -> &'static Regex {
776 static R: OnceLock<Regex> = OnceLock::new();
777 R.get_or_init(|| Regex::new(r"[^\W_][._/\\][^\W_]").unwrap())
778}
779
780fn letter_run_re() -> &'static Regex {
784 static R: OnceLock<Regex> = OnceLock::new();
785 R.get_or_init(|| Regex::new(r"[^\W\d_]{2,}").unwrap())
786}
787
788fn is_all_upper(s: &str) -> bool {
791 let mut has_cased = false;
792 for c in s.chars() {
793 if c.is_lowercase() {
794 return false;
795 }
796 if c.is_uppercase() {
797 has_cased = true;
798 }
799 }
800 has_cased
801}
802
803fn named_prose_language(segment: &str) -> Option<String> {
809 if segment.trim().is_empty() || code_line_re().is_match(segment) {
810 return None;
811 }
812 let joined = joined_re();
813 let prose = segment
814 .split_whitespace()
815 .filter(|tok| !joined.is_match(tok))
816 .collect::<Vec<_>>()
817 .join(" ");
818 let prose = if prose.chars().any(|c| c.is_lowercase()) {
819 letter_run_re()
820 .replace_all(&prose, |caps: ®ex::Captures| {
821 let m = &caps[0];
822 if is_all_upper(m) {
823 " ".to_string()
824 } else {
825 m.to_string()
826 }
827 })
828 .into_owned()
829 } else {
830 prose
831 };
832 let tokens: Vec<&str> = word_re().find_iter(&prose).map(|m| m.as_str()).collect();
833 if tokens.len() < 4 {
834 return None;
835 }
836 let lang = match latin_profile(&prose).language {
837 Some(l) if l != "en" => l,
838 _ => return None,
839 };
840 let sw = stop().get(lang.as_str())?;
841 let lowered: HashSet<String> = tokens.iter().map(|w| w.to_lowercase()).collect();
842 if lowered.iter().filter(|w| sw.contains(w.as_str())).count() < 2 {
843 return None;
844 }
845 Some(lang)
846}
847
848fn non_english_segment(state: &Value, max_chars: usize) -> Option<(String, String)> {
856 let mut leaves: Vec<String> = Vec::new();
857 iter_text(state, 0, &mut leaves);
858 let mut seen = 0usize;
859 for leaf in &leaves {
860 for seg in leaf.split('\n') {
861 if seen >= max_chars {
862 return None;
863 }
864 let seg: String = seg.chars().take(max_chars - seen).collect();
865 seen += seg.chars().count();
866 if let Some(lang) = named_prose_language(&seg) {
867 return Some((lang, seg.trim().to_string()));
868 }
869 }
870 }
871 None
872}
873
874#[derive(Debug, Clone)]
878pub struct Analysis {
879 pub script: String,
880 pub script_profile: IndexMap<String, f64>,
881 pub language: Option<String>,
882 pub is_english: bool,
883 pub language_undecided: bool,
884 pub diacritic_rate: f64,
885 pub non_latin_fraction: f64,
886 pub mixed_segment: Option<String>,
888}
889
890fn analyse_text(text: &str) -> Analysis {
894 let prof = script_profile(text);
895 let mut script = detect_script(text);
896
897 let non_latin = if !prof.is_empty() {
898 round4(1.0 - prof.get("latin").copied().unwrap_or(0.0))
899 } else {
900 0.0
901 };
902 let sum_alpha = text.chars().filter(|c| c.is_alphabetic()).count() as f64;
903 let n_non_latin = round_half_even(non_latin * sum_alpha) as i64;
904
905 if script == "latin"
906 && !non_latin_words(text).is_empty()
907 && (non_latin >= NON_LATIN_FRACTION
908 || (non_latin >= NON_LATIN_MIN_FRACTION && n_non_latin >= NON_LATIN_MIN_LETTERS))
909 {
910 let mut best: Option<&str> = None;
912 let mut best_v = f64::NEG_INFINITY;
913 for (k, v) in &prof {
914 if k == "latin" {
915 continue;
916 }
917 if *v > best_v {
918 best_v = *v;
919 best = Some(k);
920 }
921 }
922 if let Some(b) = best {
923 script = b.to_string();
924 }
925 }
926
927 if script == "unknown" {
928 return Analysis {
929 script: "unknown".to_string(),
930 script_profile: prof,
931 language: None,
932 is_english: true,
933 language_undecided: true,
934 diacritic_rate: 0.0,
935 non_latin_fraction: 0.0,
936 mixed_segment: None,
937 };
938 }
939 if script != "latin" {
940 return Analysis {
941 script,
942 script_profile: prof,
943 language: None,
944 is_english: false,
945 language_undecided: true,
946 diacritic_rate: 0.0,
947 non_latin_fraction: non_latin,
948 mixed_segment: None,
949 };
950 }
951
952 let prof_lat = latin_profile(text);
953 let lang = prof_lat.language.clone();
954 let undecided = lang.is_none();
955 let english = lang.as_deref() == Some("en") || (undecided && !prof_lat.looks_non_english);
956 Analysis {
957 script: "latin".to_string(),
958 script_profile: prof,
959 language: lang,
960 is_english: english,
961 language_undecided: undecided,
962 diacritic_rate: round4(prof_lat.diacritic_rate),
963 non_latin_fraction: non_latin,
964 mixed_segment: None,
965 }
966}
967
968fn leaf_non_english(leaf: &str) -> Option<Analysis> {
977 let mut best: Option<Analysis> = None;
978 let mut best_n: i64 = -1;
979 for line in leaf.split('\n') {
980 if line.chars().count() < 7 {
986 continue;
987 }
988 let sample: String = line.chars().take(4000).collect();
989 if sample.trim().is_empty() || code_line_re().is_match(&sample) {
990 continue;
991 }
992 let det = analyse_text(&sample);
993 if det.is_english {
994 continue;
995 }
996 if matches!(det.language.as_deref(), Some(l) if l != "en") {
997 if named_prose_language(&sample).is_none() {
998 continue;
999 }
1000 } else if det.script != "latin" && det.script != "unknown" {
1001 let n_alpha = sample.chars().filter(|c| c.is_alphabetic()).count() as i64;
1002 if non_latin_words(&sample).is_empty() || n_alpha < NON_LATIN_MIN_LETTERS {
1003 continue;
1004 }
1005 } else if !(det.language_undecided
1006 && det.diacritic_rate >= NON_EN_DIACRITIC_RATE
1007 && word_re().find_iter(&sample).count() >= 4)
1008 {
1009 continue;
1010 }
1011 let n_alpha = sample.chars().filter(|c| c.is_alphabetic()).count() as i64;
1012 if n_alpha > best_n {
1013 best_n = n_alpha;
1014 best = Some(det);
1015 }
1016 }
1017 best
1018}
1019
1020pub fn analyse(state: &Value) -> Analysis {
1021 let mut result = analyse_text(&state_text(state, 4000));
1022 if result.script == "latin" && result.is_english {
1027 let mut leaves: Vec<String> = Vec::new();
1028 iter_text(state, 0, &mut leaves);
1029 if leaves.len() > 1 || leaves.iter().any(|l| l.contains('\n')) {
1030 if let Some((seg_lang, seg)) = non_english_segment(state, 4000) {
1031 result.language = Some(seg_lang);
1032 result.is_english = false;
1033 result.language_undecided = false;
1034 result.mixed_segment = Some(seg);
1035 }
1036 }
1037 }
1038 if matches!(state, Value::String(_) | Value::Null) || !result.is_english {
1042 return result;
1043 }
1044 let mut leaves: Vec<String> = Vec::new();
1045 iter_text(state, 0, &mut leaves);
1046 let mut best: Option<Analysis> = None;
1047 let mut best_n: i64 = -1;
1048 for leaf in &leaves {
1049 let Some(det) = leaf_non_english(leaf) else {
1050 continue;
1051 };
1052 let capped: String = leaf.chars().take(4000).collect();
1053 let n_alpha = capped.chars().filter(|c| c.is_alphabetic()).count() as i64;
1054 if n_alpha > best_n {
1055 best_n = n_alpha;
1056 best = Some(det);
1057 }
1058 }
1059 if let Some(det) = best {
1060 result.language = det.language;
1061 result.is_english = false;
1062 result.language_undecided = det.language_undecided;
1063 }
1064 result
1065}
1066
1067pub fn is_english(state: &Value) -> bool {
1069 analyse(state).is_english
1070}
1071
1072#[cfg(test)]
1073mod tests {
1074 use super::*;
1075 use serde_json::json;
1076
1077 fn s(t: &str) -> Value {
1078 Value::String(t.to_string())
1079 }
1080
1081 #[test]
1082 fn detects_devanagari_as_non_latin() {
1083 assert_eq!(detect_script("मुझसे दो बार शुल्क लिया गया"), "devanagari");
1084 }
1085
1086 #[test]
1087 fn routes_english_latin_to_english() {
1088 let a = analyse(&s("Please refund the duplicate charge"));
1089 assert_eq!(a.script, "latin");
1090 assert!(a.is_english);
1091 assert_eq!(a.language.as_deref(), Some("en"));
1092 }
1093
1094 #[test]
1095 fn routes_german_latin_to_non_english() {
1096 assert!(!is_english(&s("Der Kunde wurde zweimal belastet")));
1097 }
1098
1099 #[test]
1100 fn collision_word_repeat_counts_once() {
1101 assert_eq!(
1105 latin_profile("do do do do please refund me")
1106 .language
1107 .as_deref(),
1108 Some("en")
1109 );
1110 assert_eq!(
1112 latin_profile("reduzieren der helligkeit der lichter")
1113 .language
1114 .as_deref(),
1115 Some("de")
1116 );
1117 }
1118
1119 #[test]
1120 fn detects_french_multilingual_signals() {
1121 let a = analyse(&s("Je voudrais un remboursement pour la commande"));
1122 assert!(!a.is_english);
1123 assert_eq!(a.language.as_deref(), Some("fr"));
1124 }
1125
1126 #[test]
1127 fn unknown_no_letters_is_english_and_undecided() {
1128 let a = analyse(&s("123 !!!"));
1129 assert_eq!(a.script, "unknown");
1130 assert!(a.is_english);
1131 assert!(a.language_undecided);
1132 }
1133
1134 #[test]
1135 fn cjk_han_is_not_english() {
1136 let a = analyse(&s("请退还重复的费用"));
1137 assert_eq!(a.script, "han");
1138 assert!(!a.is_english);
1139 }
1140
1141 #[test]
1142 fn hindi_devanagari_is_not_english() {
1143 let a = analyse(&s("मुझसे दो बार शुल्क लिया गया"));
1144 assert_eq!(a.script, "devanagari");
1145 assert!(!a.is_english);
1146 }
1147
1148 #[test]
1149 fn romanized_bangla_detected() {
1150 let a = analyse(&s("ami ekta refund chai amar order er jonno"));
1151 assert_eq!(a.script, "latin");
1152 assert_eq!(a.language.as_deref(), Some("bn"));
1153 assert!(!a.is_english);
1154 }
1155
1156 #[test]
1157 fn url_only_not_misdetected_as_foreign() {
1158 let a = analyse(&s("acme.com foo.com bar.com test.com"));
1160 assert_eq!(a.language, None);
1161 assert!(a.is_english);
1162 }
1163
1164 #[test]
1165 fn script_profile_latin_first_and_fractions() {
1166 let p = script_profile("abc देव");
1167 let keys: Vec<&String> = p.keys().collect();
1169 assert_eq!(keys[0], "latin");
1170 assert!(p.contains_key("devanagari"));
1171 let sum: f64 = p.values().sum();
1172 assert!((sum - 1.0).abs() < 1e-9);
1173 }
1174
1175 #[test]
1176 fn nested_state_is_flattened() {
1177 let state = json!({"a": "Please refund", "b": ["the", "duplicate", "charge"]});
1178 assert!(is_english(&state));
1179 }
1180
1181 #[test]
1182 fn round4_is_half_to_even() {
1183 assert_eq!(round_half_even(2.5), 2.0);
1184 assert_eq!(round_half_even(3.5), 4.0);
1185 }
1186
1187 #[test]
1188 fn mixed_field_makes_state_non_english() {
1189 let state = json!({
1192 "customer": "Eu preciso de ajuda com a minha conta pois fui cobrado duas vezes",
1193 "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"
1194 });
1195 let a = analyse(&state);
1196 assert_eq!(a.language.as_deref(), Some("pt"));
1197 assert!(!a.is_english);
1198 assert_eq!(
1199 a.mixed_segment.as_deref(),
1200 Some("Eu preciso de ajuda com a minha conta pois fui cobrado duas vezes")
1201 );
1202 }
1203
1204 #[test]
1205 fn mixed_segment_ignores_code_lines() {
1206 let a = analyse(&s(
1209 "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",
1210 ));
1211 assert!(a.is_english);
1212 assert_eq!(a.language.as_deref(), Some("en"));
1213 assert_eq!(a.mixed_segment, None);
1214 }
1215
1216 #[test]
1217 fn single_line_never_reports_mixed_segment() {
1218 let a = analyse(&s("Please refund the duplicate charge on my account today"));
1220 assert_eq!(a.mixed_segment, None);
1221 }
1222
1223 #[test]
1224 fn one_loanword_keeps_plain_english() {
1225 let a = analyse(&s(
1228 "Please send José the invoice and the refund for his order today",
1229 ));
1230 assert_eq!(a.language.as_deref(), Some("en"));
1231 assert!(a.is_english);
1232 let a = analyse(&s(
1233 "I would like a refund for the résumé service I paid for twice this month",
1234 ));
1235 assert_eq!(a.language.as_deref(), Some("en"));
1236 assert!(a.is_english);
1237 }
1238
1239 #[test]
1240 fn loanword_rescue_is_bounded() {
1241 let a = analyse(&s(
1243 "We met at the café for a naïve chat about the résumé and the soirée plans",
1244 ));
1245 assert!(!a.is_english);
1246 let a = analyse(&s(
1248 "Jag har blivit debiterad två gånger och vill ha pengarna tillbaka nu",
1249 ));
1250 assert!(!a.is_english);
1251 }
1252
1253 #[test]
1254 fn per_leaf_detection_finds_hidden_non_english_field() {
1255 let big_en =
1259 "please help me with the account issue and the refund for the order ".repeat(80);
1260 let state = json!({
1261 "a_log": big_en,
1262 "z_customer": "Mein Konto wurde zweimal belastet und ich brauche dringend Hilfe",
1263 });
1264 let a = analyse(&state);
1265 assert_eq!(a.language.as_deref(), Some("de"));
1266 assert!(!a.is_english);
1267 assert_eq!(a.mixed_segment, None);
1268 }
1269}