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(
190 "sv",
191 [
192 "jag",
193 "är",
194 "och",
195 "inte",
196 "att",
197 "från",
198 "till",
199 "behöver",
200 "får",
201 "skulle",
202 "ska",
203 "vill",
204 "måste",
205 "också",
206 "dessa",
207 "detta",
208 "säger",
209 "upp",
210 "utan",
211 "mitt",
212 "min",
213 "om",
214 "kommer",
215 "här",
216 "två",
217 "vi",
218 "nästa",
219 "gör",
220 "göra",
221 "hjälp",
222 "hjälpa",
223 "mig",
224 "återbetalning",
225 "återbetala",
226 "faktura",
227 "gång",
228 "gånger",
229 "hittar",
230 "inställningen",
231 "inställningarna",
232 "lösenord",
233 "när",
234 "öppnar",
235 "spårningen",
236 "aterbetalning",
237 "aterbetala",
238 "behover",
239 "fel",
240 "ganger",
241 "hjalp",
242 "hjalpa",
243 "installningen",
244 "installningarna",
245 "kraschar",
246 "kvittot",
247 "losenord",
248 "nar",
249 "oppnar",
250 "paket",
251 "skicka",
252 "sparningen",
253 "tva",
254 "uppdaterats",
255 "blivit",
256 "debiterade",
257 "appen",
258 ]
259 .into_iter()
260 .collect(),
261 );
262 m.insert(
263 "ro",
264 [
265 "și", "să", "este", "sunt", "care", "pentru", "din", "dar", "după", "până", "fără",
266 "ale", "lui", "în", "fost", "acum", "vreau", "trebuie", "foarte", "acest",
267 "această", "acesta", "aceasta", "mi", "ți", "vă", "nu",
268 ]
269 .into_iter()
270 .collect(),
271 );
272 m.insert(
273 "bn",
274 [
275 "ami",
276 "amar",
277 "amake",
278 "amra",
279 "amader",
280 "apni",
281 "apnar",
282 "apnake",
283 "apnara",
284 "tumi",
285 "tomar",
286 "tomake",
287 "tomra",
288 "tader",
289 "ota",
290 "eita",
291 "oita",
292 "ekta",
293 "ei",
294 "oi",
295 "ki",
296 "keno",
297 "kivabe",
298 "kibhabe",
299 "kothay",
300 "kokhon",
301 "kobe",
302 "koto",
303 "kintu",
304 "jodi",
305 "tahole",
306 "ar",
307 "theke",
308 "jonno",
309 "sathe",
310 "shathe",
311 "diye",
312 "niye",
313 "moddhe",
314 "kore",
315 "korte",
316 "korchi",
317 "korsi",
318 "korbo",
319 "korechi",
320 "koreche",
321 "korun",
322 "koren",
323 "korlam",
324 "hobe",
325 "hoyeche",
326 "hoise",
327 "hocche",
328 "hoyni",
329 "chai",
330 "chaina",
331 "lagbe",
332 "parchi",
333 "parbo",
334 "parchina",
335 "peyechi",
336 "paini",
337 "dite",
338 "dilam",
339 "diyechi",
340 "nai",
341 "khub",
342 "onek",
343 "ekhon",
344 "akhon",
345 "ekhono",
346 "abar",
347 "ekbar",
348 "duibar",
349 "ajke",
350 "kalke",
351 "taka",
352 "bhalo",
353 "valo",
354 "kharap",
355 "shomossa",
356 "somossa",
357 "dhonnobad",
358 "bhai",
359 "shob",
360 "keu",
361 "kichu",
362 "bolte",
363 "bolun",
364 "parben",
365 "asbe",
366 "jabe",
367 "pabo",
368 "ferot",
369 "dorkar",
370 "hoye",
371 "geche",
372 "gese",
373 ]
374 .into_iter()
375 .collect(),
376 );
377 m.insert(
378 "az",
379 [
380 "və", "ve", "bir", "bu", "üçün", "ucun", "ilə", "ile", "olan", "olub", "olmasa",
381 "var", "yox", "yoxdur", "mən", "sən", "biz", "siz", "onlar", "daha", "çox", "cox",
382 "hər", "nə", "kimi", "görə", "sonra", "əgər", "eger", "deyil", "lakin", "amma",
383 "ancaq", "artıq", "artiq", "də", "isə", "həm", "yalnız", "yalniz",
384 ]
385 .into_iter()
386 .collect(),
387 );
388 m
389 })
390}
391
392const DIACRITICS: &str = concat!(
394 "àâäãáåçéèêëíìîïñóòôöõøúùûüýÿßæœ", "ăâîșțşţ", "ąćęłńśźż", "čďěňřšťůž", "őű", "ğı", "āēģīķļņūž", "đ", "ə", );
404
405fn non_en_diacritics() -> &'static HashSet<char> {
406 static S: OnceLock<HashSet<char>> = OnceLock::new();
407 S.get_or_init(|| DIACRITICS.chars().collect())
408}
409
410fn short_swedish_words() -> &'static HashSet<&'static str> {
415 static S: OnceLock<HashSet<&'static str>> = OnceLock::new();
416 S.get_or_init(|| {
417 [
418 "åtkomst",
419 "atkomst",
420 "lösenord",
421 "losenord",
422 "fakturan",
423 "betalningen",
424 "inloggningen",
425 "glömt",
426 "glomt",
427 "behöver",
428 "behover",
429 "återbetalning",
430 "aterbetalning",
431 "kvitto",
432 "kvittot",
433 "spårningen",
434 "sparningen",
435 "inställningen",
436 "installningen",
437 "felmeddelande",
438 "abonnemanget",
439 ]
440 .into_iter()
441 .collect()
442 })
443}
444
445fn nordic_overlap_words() -> &'static HashSet<&'static str> {
450 static S: OnceLock<HashSet<&'static str>> = OnceLock::new();
451 S.get_or_init(|| {
452 [
453 "hej", "ja", "nej", "jo", "tack", "mig", "min", "om", "kommer", "får", "skulle", "vi",
454 ]
455 .into_iter()
456 .collect()
457 })
458}
459
460fn shared_words() -> &'static HashSet<&'static str> {
462 static S: OnceLock<HashSet<&'static str>> = OnceLock::new();
463 S.get_or_init(|| {
464 let mut counts: HashMap<&'static str, usize> = HashMap::new();
465 for words in stop().values() {
466 for w in words {
467 *counts.entry(*w).or_insert(0) += 1;
468 }
469 }
470 let mut shared: HashSet<&'static str> = counts
471 .into_iter()
472 .filter(|(_, n)| *n > 1)
473 .map(|(w, _)| w)
474 .collect();
475 shared.extend(nordic_overlap_words().iter().copied());
476 shared
477 })
478}
479
480fn en_only_words() -> &'static HashSet<&'static str> {
483 static S: OnceLock<HashSet<&'static str>> = OnceLock::new();
484 S.get_or_init(|| {
485 let en = &stop()["en"];
486 let shared = shared_words();
487 en.iter().copied().filter(|w| !shared.contains(w)).collect()
488 })
489}
490
491fn en_collision_words() -> &'static HashSet<&'static str> {
500 static S: OnceLock<HashSet<&'static str>> = OnceLock::new();
501 S.get_or_init(|| {
502 const CANDIDATES: [&str; 8] = ["come", "son", "do", "care", "todo", "im", "per", "plus"];
503 let mut non_en: HashSet<&'static str> = HashSet::new();
504 for (lg, sw) in stop() {
505 if *lg == "en" {
506 continue;
507 }
508 non_en.extend(sw.iter().copied());
509 }
510 CANDIDATES
511 .into_iter()
512 .filter(|w| non_en.contains(w))
513 .collect()
514 })
515}
516
517fn english_rescued_by_words(words: &[&str], diac_rate: f64) -> bool {
526 if diac_rate >= ENGLISH_RESCUE_DIACRITIC_RATE {
527 return false;
528 }
529 let word_set: HashSet<&str> = words.iter().copied().collect();
530 let en_only = en_only_words();
531 if word_set.iter().filter(|w| en_only.contains(*w)).count() < 2 {
532 return false;
533 }
534 let diac = non_en_diacritics();
535 word_set
536 .iter()
537 .filter(|w| w.chars().any(|ch| diac.contains(&ch)))
538 .count()
539 <= 1
540}
541
542fn word_re() -> &'static Regex {
543 static R: OnceLock<Regex> = OnceLock::new();
544 R.get_or_init(|| Regex::new(r"[^\W\d_]+").unwrap())
545}
546
547fn identifier_re() -> &'static Regex {
552 static R: OnceLock<Regex> = OnceLock::new();
553 R.get_or_init(|| Regex::new(r"[\w-]*(?:[.@][\w-]+)+").unwrap())
554}
555
556fn is_combining(ch: char) -> bool {
558 matches!(
559 get_general_category(ch),
560 GeneralCategory::NonspacingMark
561 | GeneralCategory::SpacingMark
562 | GeneralCategory::EnclosingMark
563 )
564}
565
566fn is_latin_detect(cp: u32) -> bool {
568 cp < 0x02B0
569 || (0x1E00..=0x1EFF).contains(&cp)
570 || (0xFF21..=0xFF3A).contains(&cp)
571 || (0xFF41..=0xFF5A).contains(&cp)
572}
573
574fn is_latin_script_of(cp: u32) -> bool {
576 cp < 0x0250
577 || (0x1E00..=0x1EFF).contains(&cp)
578 || (0xFF21..=0xFF3A).contains(&cp)
579 || (0xFF41..=0xFF5A).contains(&cp)
580}
581
582fn in_ranges(cp: u32, ranges: &[(u32, u32)]) -> bool {
583 ranges.iter().any(|&(lo, hi)| lo <= cp && cp <= hi)
584}
585
586fn round4(x: f64) -> f64 {
588 round_half_even(x * 10000.0) / 10000.0
589}
590
591fn round_half_even(x: f64) -> f64 {
593 let floor = x.floor();
594 let diff = x - floor;
595 if diff < 0.5 {
596 floor
597 } else if diff > 0.5 {
598 floor + 1.0
599 } else {
600 if (floor as i64) % 2 == 0 {
602 floor
603 } else {
604 floor + 1.0
605 }
606 }
607}
608
609fn iter_text(v: &Value, depth: usize, out: &mut Vec<String>) {
612 if depth > 6 {
613 return;
614 }
615 match v {
616 Value::Null => {}
617 Value::String(s) => out.push(s.clone()),
618 Value::Array(a) => {
619 for x in a {
620 iter_text(x, depth + 1, out);
621 }
622 }
623 Value::Object(m) => {
624 for x in m.values() {
625 iter_text(x, depth + 1, out);
626 }
627 }
628 _ => {}
629 }
630}
631
632pub fn state_text(state: &Value, max_chars: usize) -> String {
634 let mut leaves: Vec<String> = Vec::new();
635 iter_text(state, 0, &mut leaves);
636 let mut parts: Vec<String> = Vec::new();
639 let mut budget = max_chars as isize;
640 for leaf in leaves {
641 if budget <= 0 {
642 break;
643 }
644 let leaf_len = leaf.chars().count() as isize;
645 if leaf_len > budget {
646 parts.push(leaf.chars().take(budget as usize).collect());
647 break;
648 }
649 parts.push(leaf);
650 budget -= leaf_len + 1;
652 }
653 parts.join(" ").chars().take(max_chars).collect()
654}
655
656pub fn detect_script(text: &str) -> String {
660 let mut counts: IndexMap<&'static str, i64> = IndexMap::new();
661 let mut latin: i64 = 0;
662 for ch in text.chars() {
663 if !ch.is_alphabetic() {
664 continue;
665 }
666 let cp = ch as u32;
667 if is_latin_detect(cp) {
668 latin += 1;
669 continue;
670 }
671 let mut found: Option<&'static str> = None;
672 for (name, ranges) in SCRIPT_RANGES {
673 if in_ranges(cp, ranges) {
674 found = Some(name);
675 break;
676 }
677 }
678 let key = found.unwrap_or("other");
679 *counts.entry(key).or_insert(0) += 1;
680 }
681 counts.insert("latin", latin);
682 let total: i64 = counts.values().sum();
683 if total == 0 {
684 return "unknown".to_string();
685 }
686 let mut best: &str = "";
688 let mut best_n: i64 = i64::MIN;
689 for (k, v) in &counts {
690 if *v > best_n {
691 best_n = *v;
692 best = k;
693 }
694 }
695 best.to_string()
696}
697
698pub fn script_profile(text: &str) -> IndexMap<String, f64> {
700 let mut counts: IndexMap<&'static str, i64> = IndexMap::new();
701 counts.insert("latin", 0);
702 for ch in text.chars() {
703 if !ch.is_alphabetic() {
704 continue;
705 }
706 let cp = ch as u32;
707 if is_latin_detect(cp) {
708 *counts.get_mut("latin").unwrap() += 1;
709 continue;
710 }
711 let mut found: Option<&'static str> = None;
712 for (name, ranges) in SCRIPT_RANGES {
713 if in_ranges(cp, ranges) {
714 found = Some(name);
715 break;
716 }
717 }
718 let key = found.unwrap_or("other");
719 *counts.entry(key).or_insert(0) += 1;
720 }
721 let total: i64 = counts.values().sum();
722 let mut out: IndexMap<String, f64> = IndexMap::new();
723 if total == 0 {
724 return out;
725 }
726 for (k, v) in &counts {
727 if *v != 0 {
728 out.insert((*k).to_string(), *v as f64 / total as f64);
729 }
730 }
731 out
732}
733
734fn script_of(ch: char) -> Option<&'static str> {
735 let cp = ch as u32;
736 if is_latin_script_of(cp) {
737 return None;
738 }
739 for (name, ranges) in SCRIPT_RANGES {
740 if in_ranges(cp, ranges) {
741 return Some(name);
742 }
743 }
744 None
745}
746
747fn non_latin_words(text: &str) -> Vec<String> {
749 let mut runs: Vec<String> = Vec::new();
750 let mut cur = String::new();
751 let mut script: Option<&'static str> = None;
752 for ch in text.chars() {
753 if is_combining(ch) {
754 continue;
755 }
756 let s = script_of(ch);
757 if s.is_some() && s == script {
758 cur.push(ch);
759 continue;
760 }
761 if !cur.is_empty() {
762 runs.push(std::mem::take(&mut cur));
763 }
764 match s {
765 Some(_) => {
766 cur = ch.to_string();
767 script = s;
768 }
769 None => {
770 cur = String::new();
771 script = None;
772 }
773 }
774 }
775 if !cur.is_empty() {
776 runs.push(cur);
777 }
778 runs.into_iter()
779 .filter(|w| {
780 w.chars().count() >= 2 && !w.chars().next().map(|c| c.is_uppercase()).unwrap_or(false)
781 })
782 .collect()
783}
784
785#[derive(Debug, Clone)]
789pub struct LatinProfile {
790 pub language: Option<String>,
791 pub english_hits: usize,
792 pub diacritic_rate: f64,
793 pub looks_non_english: bool,
794}
795
796pub fn latin_profile(text: &str) -> LatinProfile {
797 let stripped = identifier_re().replace_all(text, " ");
799 let normalized = stripped.replace('\u{0130}', "i").to_lowercase();
800 let words: Vec<&str> = word_re()
801 .find_iter(&normalized)
802 .map(|m| m.as_str())
803 .collect();
804
805 let lowered = text.to_lowercase();
806 let diac_set = non_en_diacritics();
807 let diac = lowered.chars().filter(|ch| diac_set.contains(ch)).count();
808 let len = lowered.chars().count();
809 let diac_rate = diac as f64 / (std::cmp::max(1, len) as f64);
810 let non_english = diac_rate >= NON_EN_DIACRITIC_RATE;
811
812 let word_set_all: HashSet<&str> = words.iter().copied().collect();
815 let en_only = en_only_words();
816 let nordic_overlap = word_set_all
817 .iter()
818 .any(|w| nordic_overlap_words().contains(*w))
819 && !word_set_all.iter().any(|w| en_only.contains(*w));
820
821 if words.len() > 1
823 && words.len() < 4
824 && word_set_all
825 .iter()
826 .any(|w| short_swedish_words().contains(*w))
827 {
828 return LatinProfile {
829 language: Some("sv".to_string()),
830 english_hits: 0,
831 diacritic_rate: diac_rate,
832 looks_non_english: non_english,
833 };
834 }
835
836 if words.len() < 4 {
837 return LatinProfile {
838 language: None,
839 english_hits: 0,
840 diacritic_rate: diac_rate,
841 looks_non_english: non_english || nordic_overlap,
842 };
843 }
844
845 let stop = stop();
846 let shared = shared_words();
847
848 let mut counts: HashMap<&str, i64> = HashMap::new();
851 for w in &words {
852 *counts.entry(*w).or_insert(0) += 1;
853 }
854 let collision = en_collision_words();
855 let mut scores: IndexMap<&'static str, i64> = IndexMap::new();
856 for (lg, sw) in stop {
857 let s: i64 = counts
858 .iter()
859 .filter(|(w, _)| sw.contains(**w))
860 .map(|(w, n)| if collision.contains(*w) { 1 } else { *n })
861 .sum();
862 scores.insert(lg, s);
863 }
864 let en = scores.get("en").copied().unwrap_or(0);
865
866 let mut best_lg: Option<&'static str> = None;
868 let mut best: i64 = 0;
869 for (lg, sw) in stop {
870 if *lg == "en" {
871 continue;
872 }
873 let has_evidence = word_set_all
874 .iter()
875 .any(|w| sw.contains(*w) && !shared.contains(*w));
876 if !has_evidence {
877 continue;
878 }
879 let s = *scores.get(lg).unwrap();
880 if s > best {
881 best = s;
882 best_lg = Some(lg);
883 }
884 }
885
886 let mut language: Option<String> = None;
887 if best_lg.is_some() && best >= std::cmp::max(2, en + 2) {
889 language = best_lg.map(|s| s.to_string());
891 } else if best_lg == Some("sv")
892 && word_set_all.contains("inte")
893 && word_set_all.contains("kan")
894 && matches!(
895 words.first().copied(),
896 Some("kan") | Some("jag") | Some("vi")
897 )
898 && en <= 1
899 {
900 language = best_lg.map(|s| s.to_string());
904 } else if best_lg.is_some() && non_english && best >= std::cmp::max(2, en) {
905 language = best_lg.map(|s| s.to_string());
907 } else if en > 0 && (!non_english || english_rescued_by_words(&words, diac_rate)) {
908 language = Some("en".to_string());
909 }
910
911 let looks_non_english = non_english || (language.is_none() && nordic_overlap);
912 LatinProfile {
913 language,
914 english_hits: en as usize,
915 diacritic_rate: diac_rate,
916 looks_non_english,
917 }
918}
919
920pub fn guess_latin_language(text: &str) -> Option<String> {
922 latin_profile(text).language
923}
924
925fn code_line_re() -> &'static Regex {
931 static R: OnceLock<Regex> = OnceLock::new();
932 R.get_or_init(|| Regex::new(r"[=;{}\[\]]|\w\(").unwrap())
933}
934
935fn joined_re() -> &'static Regex {
939 static R: OnceLock<Regex> = OnceLock::new();
940 R.get_or_init(|| Regex::new(r"[^\W_][._/\\][^\W_]").unwrap())
941}
942
943fn letter_run_re() -> &'static Regex {
947 static R: OnceLock<Regex> = OnceLock::new();
948 R.get_or_init(|| Regex::new(r"[^\W\d_]{2,}").unwrap())
949}
950
951fn is_all_upper(s: &str) -> bool {
954 let mut has_cased = false;
955 for c in s.chars() {
956 if c.is_lowercase() {
957 return false;
958 }
959 if c.is_uppercase() {
960 has_cased = true;
961 }
962 }
963 has_cased
964}
965
966fn non_latin_carries(non_latin: f64, n_non_latin: i64) -> bool {
969 non_latin >= NON_LATIN_FRACTION
970 || (non_latin >= NON_LATIN_MIN_FRACTION && n_non_latin >= NON_LATIN_MIN_LETTERS)
971}
972
973const SHOUTED_MIN_STOPWORD: usize = 4;
978const SHOUTED_MIN_WORD: usize = 5;
979
980fn is_shouted(text: &str) -> bool {
984 text.chars().any(|c| c.is_uppercase()) && !text.chars().any(|c| c.is_lowercase())
985}
986
987fn shouted_evidence(tokens: &[&str], lang: &str, diacritic_rate: f64) -> bool {
991 if !tokens.iter().any(|t| t.chars().count() >= SHOUTED_MIN_WORD) {
992 return false;
993 }
994 if diacritic_rate >= NON_EN_DIACRITIC_RATE {
995 return true;
996 }
997 let Some(sw) = stop().get(lang) else {
998 return false;
999 };
1000 tokens.iter().any(|t| {
1001 let lw = t.to_lowercase();
1002 lw.chars().count() >= SHOUTED_MIN_STOPWORD && sw.contains(lw.as_str())
1003 })
1004}
1005
1006fn named_prose_language(segment: &str) -> Option<String> {
1014 if segment.trim().is_empty() || code_line_re().is_match(segment) {
1015 return None;
1016 }
1017 let joined = joined_re();
1018 let prose = segment
1019 .split_whitespace()
1020 .filter(|tok| !joined.is_match(tok))
1021 .collect::<Vec<_>>()
1022 .join(" ");
1023 let shouted = is_shouted(&prose);
1024 let prose = if !shouted {
1025 letter_run_re()
1026 .replace_all(&prose, |caps: ®ex::Captures| {
1027 let m = &caps[0];
1028 if is_all_upper(m) {
1029 " ".to_string()
1030 } else {
1031 m.to_string()
1032 }
1033 })
1034 .into_owned()
1035 } else {
1036 prose
1037 };
1038 let tokens: Vec<&str> = word_re().find_iter(&prose).map(|m| m.as_str()).collect();
1039 if tokens.len() < 4 {
1040 return None;
1041 }
1042 let prof = latin_profile(&prose);
1043 let lang = match prof.language {
1044 Some(ref l) if l != "en" => l.clone(),
1045 _ => return None,
1046 };
1047 if shouted && !shouted_evidence(&tokens, &lang, prof.diacritic_rate) {
1048 return None;
1049 }
1050 let sw = stop().get(lang.as_str())?;
1051 let lowered: HashSet<String> = tokens.iter().map(|w| w.to_lowercase()).collect();
1052 if lowered.iter().filter(|w| sw.contains(w.as_str())).count() < 2 {
1053 return None;
1054 }
1055 Some(lang)
1056}
1057
1058fn non_english_segment(state: &Value, max_chars: usize) -> Option<(String, String)> {
1066 let mut leaves: Vec<String> = Vec::new();
1067 iter_text(state, 0, &mut leaves);
1068 let mut seen = 0usize;
1069 for leaf in &leaves {
1070 for seg in leaf.split('\n') {
1071 if seen >= max_chars {
1072 return None;
1073 }
1074 let seg: String = seg.chars().take(max_chars - seen).collect();
1075 seen += seg.chars().count();
1076 if let Some(lang) = named_prose_language(&seg) {
1077 return Some((lang, seg.trim().to_string()));
1078 }
1079 }
1080 }
1081 None
1082}
1083
1084#[derive(Debug, Clone)]
1088pub struct Analysis {
1089 pub script: String,
1090 pub script_profile: IndexMap<String, f64>,
1091 pub language: Option<String>,
1092 pub is_english: bool,
1093 pub language_undecided: bool,
1094 pub diacritic_rate: f64,
1095 pub non_latin_fraction: f64,
1096 pub mixed_segment: Option<String>,
1098}
1099
1100fn analyse_text(text: &str) -> Analysis {
1104 let prof = script_profile(text);
1105 let mut script = detect_script(text);
1106
1107 let non_latin = if !prof.is_empty() {
1108 round4(1.0 - prof.get("latin").copied().unwrap_or(0.0))
1109 } else {
1110 0.0
1111 };
1112 let sum_alpha = text.chars().filter(|c| c.is_alphabetic()).count() as f64;
1113 let n_non_latin = round_half_even(non_latin * sum_alpha) as i64;
1114
1115 if script == "latin"
1116 && !non_latin_words(text).is_empty()
1117 && non_latin_carries(non_latin, n_non_latin)
1118 {
1119 let mut best: Option<&str> = None;
1121 let mut best_v = f64::NEG_INFINITY;
1122 for (k, v) in &prof {
1123 if k == "latin" {
1124 continue;
1125 }
1126 if *v > best_v {
1127 best_v = *v;
1128 best = Some(k);
1129 }
1130 }
1131 if let Some(b) = best {
1132 script = b.to_string();
1133 }
1134 }
1135
1136 if script == "unknown" {
1137 return Analysis {
1138 script: "unknown".to_string(),
1139 script_profile: prof,
1140 language: None,
1141 is_english: true,
1142 language_undecided: true,
1143 diacritic_rate: 0.0,
1144 non_latin_fraction: 0.0,
1145 mixed_segment: None,
1146 };
1147 }
1148 if script != "latin" {
1149 return Analysis {
1150 script,
1151 script_profile: prof,
1152 language: None,
1153 is_english: false,
1154 language_undecided: true,
1155 diacritic_rate: 0.0,
1156 non_latin_fraction: non_latin,
1157 mixed_segment: None,
1158 };
1159 }
1160
1161 let prof_lat = latin_profile(text);
1162 let mut lang = prof_lat.language.clone();
1163 if matches!(lang.as_deref(), Some(l) if l != "en") && !non_latin_carries(non_latin, n_non_latin)
1170 {
1171 if is_shouted(text) {
1172 let tokens: Vec<&str> = word_re().find_iter(text).map(|m| m.as_str()).collect();
1173 let ok = {
1174 let l = lang.as_deref().unwrap();
1175 shouted_evidence(&tokens, l, prof_lat.diacritic_rate)
1176 };
1177 if !ok {
1178 lang = None;
1179 }
1180 } else {
1181 let blank_run = |caps: ®ex::Captures| {
1186 let m = &caps[0];
1187 if is_all_upper(m) {
1188 " ".to_string()
1189 } else {
1190 m.to_string()
1191 }
1192 };
1193 let caps: Vec<&str> = letter_run_re()
1194 .find_iter(text)
1195 .map(|m| m.as_str())
1196 .filter(|m| is_all_upper(m))
1197 .collect();
1198 if !caps.is_empty() && !caps.iter().any(|c| c.chars().count() >= SHOUTED_MIN_WORD) {
1199 let blanked = letter_run_re().replace_all(text, blank_run);
1200 if blanked.as_ref() != text {
1201 lang = latin_profile(blanked.as_ref()).language;
1202 }
1203 }
1204 }
1205 }
1206 let undecided = lang.is_none();
1207 let english = lang.as_deref() == Some("en") || (undecided && !prof_lat.looks_non_english);
1208 Analysis {
1209 script: "latin".to_string(),
1210 script_profile: prof,
1211 language: lang,
1212 is_english: english,
1213 language_undecided: undecided,
1214 diacritic_rate: round4(prof_lat.diacritic_rate),
1215 non_latin_fraction: non_latin,
1216 mixed_segment: None,
1217 }
1218}
1219
1220fn leaf_non_english(leaf: &str) -> Option<Analysis> {
1229 let mut best: Option<Analysis> = None;
1230 let mut best_n: i64 = -1;
1231 for line in leaf.split('\n') {
1232 if line.chars().count() < 7 {
1238 continue;
1239 }
1240 let sample: String = line.chars().take(4000).collect();
1241 if sample.trim().is_empty() || code_line_re().is_match(&sample) {
1242 continue;
1243 }
1244 let det = analyse_text(&sample);
1245 if det.is_english {
1246 continue;
1247 }
1248 let named = matches!(det.language.as_deref(), Some(l) if l != "en")
1254 && named_prose_language(&sample).is_some();
1255 if !named {
1256 if det.script != "latin" && det.script != "unknown" {
1257 let n_alpha = sample.chars().filter(|c| c.is_alphabetic()).count() as i64;
1258 if non_latin_words(&sample).is_empty() || n_alpha < NON_LATIN_MIN_LETTERS {
1259 continue;
1260 }
1261 } else if !(det.diacritic_rate >= NON_EN_DIACRITIC_RATE
1262 && word_re().find_iter(&sample).count() >= 4)
1263 {
1264 continue;
1265 }
1266 }
1267 let n_alpha = sample.chars().filter(|c| c.is_alphabetic()).count() as i64;
1268 if n_alpha > best_n {
1269 best_n = n_alpha;
1270 best = Some(det);
1271 }
1272 }
1273 best
1274}
1275
1276pub fn analyse(state: &Value) -> Analysis {
1277 let mut result = analyse_text(&state_text(state, 4000));
1278 if result.script == "latin" && result.is_english {
1283 let mut leaves: Vec<String> = Vec::new();
1284 iter_text(state, 0, &mut leaves);
1285 if leaves.len() > 1 || leaves.iter().any(|l| l.contains('\n')) {
1286 if let Some((seg_lang, seg)) = non_english_segment(state, 4000) {
1287 result.language = Some(seg_lang);
1288 result.is_english = false;
1289 result.language_undecided = false;
1290 result.mixed_segment = Some(seg);
1291 }
1292 }
1293 }
1294 if matches!(state, Value::String(_) | Value::Null) || !result.is_english {
1298 return result;
1299 }
1300 let mut leaves: Vec<String> = Vec::new();
1301 iter_text(state, 0, &mut leaves);
1302 let mut best: Option<Analysis> = None;
1303 let mut best_n: i64 = -1;
1304 for leaf in &leaves {
1305 let Some(det) = leaf_non_english(leaf) else {
1306 continue;
1307 };
1308 let capped: String = leaf.chars().take(4000).collect();
1309 let n_alpha = capped.chars().filter(|c| c.is_alphabetic()).count() as i64;
1310 if n_alpha > best_n {
1311 best_n = n_alpha;
1312 best = Some(det);
1313 }
1314 }
1315 if let Some(det) = best {
1316 result.language = det.language;
1317 result.is_english = false;
1318 result.language_undecided = det.language_undecided;
1319 }
1320 result
1321}
1322
1323pub fn is_english(state: &Value) -> bool {
1325 analyse(state).is_english
1326}
1327
1328#[cfg(test)]
1329mod tests {
1330 use super::*;
1331 use serde_json::json;
1332
1333 fn s(t: &str) -> Value {
1334 Value::String(t.to_string())
1335 }
1336
1337 #[test]
1338 fn detects_devanagari_as_non_latin() {
1339 assert_eq!(detect_script("मुझसे दो बार शुल्क लिया गया"), "devanagari");
1340 }
1341
1342 #[test]
1343 fn routes_english_latin_to_english() {
1344 let a = analyse(&s("Please refund the duplicate charge"));
1345 assert_eq!(a.script, "latin");
1346 assert!(a.is_english);
1347 assert_eq!(a.language.as_deref(), Some("en"));
1348 }
1349
1350 #[test]
1351 fn routes_german_latin_to_non_english() {
1352 assert!(!is_english(&s("Der Kunde wurde zweimal belastet")));
1353 }
1354
1355 #[test]
1356 fn collision_word_repeat_counts_once() {
1357 assert_eq!(
1361 latin_profile("do do do do please refund me")
1362 .language
1363 .as_deref(),
1364 Some("en")
1365 );
1366 assert_eq!(
1368 latin_profile("reduzieren der helligkeit der lichter")
1369 .language
1370 .as_deref(),
1371 Some("de")
1372 );
1373 }
1374
1375 #[test]
1376 fn detects_french_multilingual_signals() {
1377 let a = analyse(&s("Je voudrais un remboursement pour la commande"));
1378 assert!(!a.is_english);
1379 assert_eq!(a.language.as_deref(), Some("fr"));
1380 }
1381
1382 #[test]
1383 fn unknown_no_letters_is_english_and_undecided() {
1384 let a = analyse(&s("123 !!!"));
1385 assert_eq!(a.script, "unknown");
1386 assert!(a.is_english);
1387 assert!(a.language_undecided);
1388 }
1389
1390 #[test]
1391 fn cjk_han_is_not_english() {
1392 let a = analyse(&s("请退还重复的费用"));
1393 assert_eq!(a.script, "han");
1394 assert!(!a.is_english);
1395 }
1396
1397 #[test]
1398 fn hindi_devanagari_is_not_english() {
1399 let a = analyse(&s("मुझसे दो बार शुल्क लिया गया"));
1400 assert_eq!(a.script, "devanagari");
1401 assert!(!a.is_english);
1402 }
1403
1404 #[test]
1405 fn romanized_bangla_detected() {
1406 let a = analyse(&s("ami ekta refund chai amar order er jonno"));
1407 assert_eq!(a.script, "latin");
1408 assert_eq!(a.language.as_deref(), Some("bn"));
1409 assert!(!a.is_english);
1410 }
1411
1412 #[test]
1413 fn url_only_not_misdetected_as_foreign() {
1414 let a = analyse(&s("acme.com foo.com bar.com test.com"));
1416 assert_eq!(a.language, None);
1417 assert!(a.is_english);
1418 }
1419
1420 #[test]
1421 fn all_caps_acronym_line_is_not_foreign_prose() {
1422 let a = analyse(&s("MON DES EST LA"));
1426 assert_eq!(a.language, None);
1427 assert!(a.is_english);
1428 let a = analyse(&s("STORES LOS ANGELES LAS VEGAS EL PASO CLOSED"));
1429 assert_eq!(a.language, None);
1430 assert!(a.is_english);
1431 }
1432
1433 #[test]
1434 fn shouted_line_with_diacritics_is_kept_foreign() {
1435 assert!(!analyse(&s("WIE SPÄT IST ES IN KÖLN")).is_english);
1437 assert!(is_english(&s("WIE SPAET IST ES IN KOELN")));
1440 }
1441
1442 #[test]
1443 fn emphasis_capitals_do_not_erase_the_language() {
1444 assert_eq!(
1447 analyse(&s("sag mir das HEUTIGE DATUM")).language.as_deref(),
1448 Some("de")
1449 );
1450 assert_eq!(
1451 analyse(&s("quiero cancelar mi PEDIDO POR FAVOR"))
1452 .language
1453 .as_deref(),
1454 Some("es")
1455 );
1456 }
1457
1458 #[test]
1459 fn english_line_above_acronym_line_reads_english() {
1460 let a = analyse(&s("Please refund my order\nMON DES EST LA"));
1463 assert!(a.is_english);
1464 assert_eq!(a.language.as_deref(), Some("en"));
1465 }
1466
1467 #[test]
1468 fn script_profile_latin_first_and_fractions() {
1469 let p = script_profile("abc देव");
1470 let keys: Vec<&String> = p.keys().collect();
1472 assert_eq!(keys[0], "latin");
1473 assert!(p.contains_key("devanagari"));
1474 let sum: f64 = p.values().sum();
1475 assert!((sum - 1.0).abs() < 1e-9);
1476 }
1477
1478 #[test]
1479 fn nested_state_is_flattened() {
1480 let state = json!({"a": "Please refund", "b": ["the", "duplicate", "charge"]});
1481 assert!(is_english(&state));
1482 }
1483
1484 #[test]
1485 fn round4_is_half_to_even() {
1486 assert_eq!(round_half_even(2.5), 2.0);
1487 assert_eq!(round_half_even(3.5), 4.0);
1488 }
1489
1490 #[test]
1491 fn mixed_field_makes_state_non_english() {
1492 let state = json!({
1495 "customer": "Eu preciso de ajuda com a minha conta pois fui cobrado duas vezes",
1496 "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"
1497 });
1498 let a = analyse(&state);
1499 assert_eq!(a.language.as_deref(), Some("pt"));
1500 assert!(!a.is_english);
1501 assert_eq!(
1502 a.mixed_segment.as_deref(),
1503 Some("Eu preciso de ajuda com a minha conta pois fui cobrado duas vezes")
1504 );
1505 }
1506
1507 #[test]
1508 fn mixed_segment_ignores_code_lines() {
1509 let a = analyse(&s(
1512 "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",
1513 ));
1514 assert!(a.is_english);
1515 assert_eq!(a.language.as_deref(), Some("en"));
1516 assert_eq!(a.mixed_segment, None);
1517 }
1518
1519 #[test]
1520 fn single_line_never_reports_mixed_segment() {
1521 let a = analyse(&s("Please refund the duplicate charge on my account today"));
1523 assert_eq!(a.mixed_segment, None);
1524 }
1525
1526 #[test]
1527 fn one_loanword_keeps_plain_english() {
1528 let a = analyse(&s(
1531 "Please send José the invoice and the refund for his order today",
1532 ));
1533 assert_eq!(a.language.as_deref(), Some("en"));
1534 assert!(a.is_english);
1535 let a = analyse(&s(
1536 "I would like a refund for the résumé service I paid for twice this month",
1537 ));
1538 assert_eq!(a.language.as_deref(), Some("en"));
1539 assert!(a.is_english);
1540 }
1541
1542 #[test]
1543 fn loanword_rescue_is_bounded() {
1544 let a = analyse(&s(
1546 "We met at the café for a naïve chat about the résumé and the soirée plans",
1547 ));
1548 assert!(!a.is_english);
1549 let a = analyse(&s(
1551 "Jag har blivit debiterad två gånger och vill ha pengarna tillbaka nu",
1552 ));
1553 assert!(!a.is_english);
1554 }
1555
1556 #[test]
1557 fn per_leaf_detection_finds_hidden_non_english_field() {
1558 let big_en =
1562 "please help me with the account issue and the refund for the order ".repeat(80);
1563 let state = json!({
1564 "a_log": big_en,
1565 "z_customer": "Mein Konto wurde zweimal belastet und ich brauche dringend Hilfe",
1566 });
1567 let a = analyse(&state);
1568 assert_eq!(a.language.as_deref(), Some("de"));
1569 assert!(!a.is_english);
1570 assert_eq!(a.mixed_segment, None);
1571 }
1572}