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 named_prose_language(segment: &str) -> Option<String> {
972 if segment.trim().is_empty() || code_line_re().is_match(segment) {
973 return None;
974 }
975 let joined = joined_re();
976 let prose = segment
977 .split_whitespace()
978 .filter(|tok| !joined.is_match(tok))
979 .collect::<Vec<_>>()
980 .join(" ");
981 let prose = if prose.chars().any(|c| c.is_lowercase()) {
982 letter_run_re()
983 .replace_all(&prose, |caps: ®ex::Captures| {
984 let m = &caps[0];
985 if is_all_upper(m) {
986 " ".to_string()
987 } else {
988 m.to_string()
989 }
990 })
991 .into_owned()
992 } else {
993 prose
994 };
995 let tokens: Vec<&str> = word_re().find_iter(&prose).map(|m| m.as_str()).collect();
996 if tokens.len() < 4 {
997 return None;
998 }
999 let lang = match latin_profile(&prose).language {
1000 Some(l) if l != "en" => l,
1001 _ => return None,
1002 };
1003 let sw = stop().get(lang.as_str())?;
1004 let lowered: HashSet<String> = tokens.iter().map(|w| w.to_lowercase()).collect();
1005 if lowered.iter().filter(|w| sw.contains(w.as_str())).count() < 2 {
1006 return None;
1007 }
1008 Some(lang)
1009}
1010
1011fn non_english_segment(state: &Value, max_chars: usize) -> Option<(String, String)> {
1019 let mut leaves: Vec<String> = Vec::new();
1020 iter_text(state, 0, &mut leaves);
1021 let mut seen = 0usize;
1022 for leaf in &leaves {
1023 for seg in leaf.split('\n') {
1024 if seen >= max_chars {
1025 return None;
1026 }
1027 let seg: String = seg.chars().take(max_chars - seen).collect();
1028 seen += seg.chars().count();
1029 if let Some(lang) = named_prose_language(&seg) {
1030 return Some((lang, seg.trim().to_string()));
1031 }
1032 }
1033 }
1034 None
1035}
1036
1037#[derive(Debug, Clone)]
1041pub struct Analysis {
1042 pub script: String,
1043 pub script_profile: IndexMap<String, f64>,
1044 pub language: Option<String>,
1045 pub is_english: bool,
1046 pub language_undecided: bool,
1047 pub diacritic_rate: f64,
1048 pub non_latin_fraction: f64,
1049 pub mixed_segment: Option<String>,
1051}
1052
1053fn analyse_text(text: &str) -> Analysis {
1057 let prof = script_profile(text);
1058 let mut script = detect_script(text);
1059
1060 let non_latin = if !prof.is_empty() {
1061 round4(1.0 - prof.get("latin").copied().unwrap_or(0.0))
1062 } else {
1063 0.0
1064 };
1065 let sum_alpha = text.chars().filter(|c| c.is_alphabetic()).count() as f64;
1066 let n_non_latin = round_half_even(non_latin * sum_alpha) as i64;
1067
1068 if script == "latin"
1069 && !non_latin_words(text).is_empty()
1070 && (non_latin >= NON_LATIN_FRACTION
1071 || (non_latin >= NON_LATIN_MIN_FRACTION && n_non_latin >= NON_LATIN_MIN_LETTERS))
1072 {
1073 let mut best: Option<&str> = None;
1075 let mut best_v = f64::NEG_INFINITY;
1076 for (k, v) in &prof {
1077 if k == "latin" {
1078 continue;
1079 }
1080 if *v > best_v {
1081 best_v = *v;
1082 best = Some(k);
1083 }
1084 }
1085 if let Some(b) = best {
1086 script = b.to_string();
1087 }
1088 }
1089
1090 if script == "unknown" {
1091 return Analysis {
1092 script: "unknown".to_string(),
1093 script_profile: prof,
1094 language: None,
1095 is_english: true,
1096 language_undecided: true,
1097 diacritic_rate: 0.0,
1098 non_latin_fraction: 0.0,
1099 mixed_segment: None,
1100 };
1101 }
1102 if script != "latin" {
1103 return Analysis {
1104 script,
1105 script_profile: prof,
1106 language: None,
1107 is_english: false,
1108 language_undecided: true,
1109 diacritic_rate: 0.0,
1110 non_latin_fraction: non_latin,
1111 mixed_segment: None,
1112 };
1113 }
1114
1115 let prof_lat = latin_profile(text);
1116 let lang = prof_lat.language.clone();
1117 let undecided = lang.is_none();
1118 let english = lang.as_deref() == Some("en") || (undecided && !prof_lat.looks_non_english);
1119 Analysis {
1120 script: "latin".to_string(),
1121 script_profile: prof,
1122 language: lang,
1123 is_english: english,
1124 language_undecided: undecided,
1125 diacritic_rate: round4(prof_lat.diacritic_rate),
1126 non_latin_fraction: non_latin,
1127 mixed_segment: None,
1128 }
1129}
1130
1131fn leaf_non_english(leaf: &str) -> Option<Analysis> {
1140 let mut best: Option<Analysis> = None;
1141 let mut best_n: i64 = -1;
1142 for line in leaf.split('\n') {
1143 if line.chars().count() < 7 {
1149 continue;
1150 }
1151 let sample: String = line.chars().take(4000).collect();
1152 if sample.trim().is_empty() || code_line_re().is_match(&sample) {
1153 continue;
1154 }
1155 let det = analyse_text(&sample);
1156 if det.is_english {
1157 continue;
1158 }
1159 if matches!(det.language.as_deref(), Some(l) if l != "en") {
1160 if named_prose_language(&sample).is_none() {
1161 continue;
1162 }
1163 } else if det.script != "latin" && det.script != "unknown" {
1164 let n_alpha = sample.chars().filter(|c| c.is_alphabetic()).count() as i64;
1165 if non_latin_words(&sample).is_empty() || n_alpha < NON_LATIN_MIN_LETTERS {
1166 continue;
1167 }
1168 } else if !(det.language_undecided
1169 && det.diacritic_rate >= NON_EN_DIACRITIC_RATE
1170 && word_re().find_iter(&sample).count() >= 4)
1171 {
1172 continue;
1173 }
1174 let n_alpha = sample.chars().filter(|c| c.is_alphabetic()).count() as i64;
1175 if n_alpha > best_n {
1176 best_n = n_alpha;
1177 best = Some(det);
1178 }
1179 }
1180 best
1181}
1182
1183pub fn analyse(state: &Value) -> Analysis {
1184 let mut result = analyse_text(&state_text(state, 4000));
1185 if result.script == "latin" && result.is_english {
1190 let mut leaves: Vec<String> = Vec::new();
1191 iter_text(state, 0, &mut leaves);
1192 if leaves.len() > 1 || leaves.iter().any(|l| l.contains('\n')) {
1193 if let Some((seg_lang, seg)) = non_english_segment(state, 4000) {
1194 result.language = Some(seg_lang);
1195 result.is_english = false;
1196 result.language_undecided = false;
1197 result.mixed_segment = Some(seg);
1198 }
1199 }
1200 }
1201 if matches!(state, Value::String(_) | Value::Null) || !result.is_english {
1205 return result;
1206 }
1207 let mut leaves: Vec<String> = Vec::new();
1208 iter_text(state, 0, &mut leaves);
1209 let mut best: Option<Analysis> = None;
1210 let mut best_n: i64 = -1;
1211 for leaf in &leaves {
1212 let Some(det) = leaf_non_english(leaf) else {
1213 continue;
1214 };
1215 let capped: String = leaf.chars().take(4000).collect();
1216 let n_alpha = capped.chars().filter(|c| c.is_alphabetic()).count() as i64;
1217 if n_alpha > best_n {
1218 best_n = n_alpha;
1219 best = Some(det);
1220 }
1221 }
1222 if let Some(det) = best {
1223 result.language = det.language;
1224 result.is_english = false;
1225 result.language_undecided = det.language_undecided;
1226 }
1227 result
1228}
1229
1230pub fn is_english(state: &Value) -> bool {
1232 analyse(state).is_english
1233}
1234
1235#[cfg(test)]
1236mod tests {
1237 use super::*;
1238 use serde_json::json;
1239
1240 fn s(t: &str) -> Value {
1241 Value::String(t.to_string())
1242 }
1243
1244 #[test]
1245 fn detects_devanagari_as_non_latin() {
1246 assert_eq!(detect_script("मुझसे दो बार शुल्क लिया गया"), "devanagari");
1247 }
1248
1249 #[test]
1250 fn routes_english_latin_to_english() {
1251 let a = analyse(&s("Please refund the duplicate charge"));
1252 assert_eq!(a.script, "latin");
1253 assert!(a.is_english);
1254 assert_eq!(a.language.as_deref(), Some("en"));
1255 }
1256
1257 #[test]
1258 fn routes_german_latin_to_non_english() {
1259 assert!(!is_english(&s("Der Kunde wurde zweimal belastet")));
1260 }
1261
1262 #[test]
1263 fn collision_word_repeat_counts_once() {
1264 assert_eq!(
1268 latin_profile("do do do do please refund me")
1269 .language
1270 .as_deref(),
1271 Some("en")
1272 );
1273 assert_eq!(
1275 latin_profile("reduzieren der helligkeit der lichter")
1276 .language
1277 .as_deref(),
1278 Some("de")
1279 );
1280 }
1281
1282 #[test]
1283 fn detects_french_multilingual_signals() {
1284 let a = analyse(&s("Je voudrais un remboursement pour la commande"));
1285 assert!(!a.is_english);
1286 assert_eq!(a.language.as_deref(), Some("fr"));
1287 }
1288
1289 #[test]
1290 fn unknown_no_letters_is_english_and_undecided() {
1291 let a = analyse(&s("123 !!!"));
1292 assert_eq!(a.script, "unknown");
1293 assert!(a.is_english);
1294 assert!(a.language_undecided);
1295 }
1296
1297 #[test]
1298 fn cjk_han_is_not_english() {
1299 let a = analyse(&s("请退还重复的费用"));
1300 assert_eq!(a.script, "han");
1301 assert!(!a.is_english);
1302 }
1303
1304 #[test]
1305 fn hindi_devanagari_is_not_english() {
1306 let a = analyse(&s("मुझसे दो बार शुल्क लिया गया"));
1307 assert_eq!(a.script, "devanagari");
1308 assert!(!a.is_english);
1309 }
1310
1311 #[test]
1312 fn romanized_bangla_detected() {
1313 let a = analyse(&s("ami ekta refund chai amar order er jonno"));
1314 assert_eq!(a.script, "latin");
1315 assert_eq!(a.language.as_deref(), Some("bn"));
1316 assert!(!a.is_english);
1317 }
1318
1319 #[test]
1320 fn url_only_not_misdetected_as_foreign() {
1321 let a = analyse(&s("acme.com foo.com bar.com test.com"));
1323 assert_eq!(a.language, None);
1324 assert!(a.is_english);
1325 }
1326
1327 #[test]
1328 fn script_profile_latin_first_and_fractions() {
1329 let p = script_profile("abc देव");
1330 let keys: Vec<&String> = p.keys().collect();
1332 assert_eq!(keys[0], "latin");
1333 assert!(p.contains_key("devanagari"));
1334 let sum: f64 = p.values().sum();
1335 assert!((sum - 1.0).abs() < 1e-9);
1336 }
1337
1338 #[test]
1339 fn nested_state_is_flattened() {
1340 let state = json!({"a": "Please refund", "b": ["the", "duplicate", "charge"]});
1341 assert!(is_english(&state));
1342 }
1343
1344 #[test]
1345 fn round4_is_half_to_even() {
1346 assert_eq!(round_half_even(2.5), 2.0);
1347 assert_eq!(round_half_even(3.5), 4.0);
1348 }
1349
1350 #[test]
1351 fn mixed_field_makes_state_non_english() {
1352 let state = json!({
1355 "customer": "Eu preciso de ajuda com a minha conta pois fui cobrado duas vezes",
1356 "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"
1357 });
1358 let a = analyse(&state);
1359 assert_eq!(a.language.as_deref(), Some("pt"));
1360 assert!(!a.is_english);
1361 assert_eq!(
1362 a.mixed_segment.as_deref(),
1363 Some("Eu preciso de ajuda com a minha conta pois fui cobrado duas vezes")
1364 );
1365 }
1366
1367 #[test]
1368 fn mixed_segment_ignores_code_lines() {
1369 let a = analyse(&s(
1372 "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",
1373 ));
1374 assert!(a.is_english);
1375 assert_eq!(a.language.as_deref(), Some("en"));
1376 assert_eq!(a.mixed_segment, None);
1377 }
1378
1379 #[test]
1380 fn single_line_never_reports_mixed_segment() {
1381 let a = analyse(&s("Please refund the duplicate charge on my account today"));
1383 assert_eq!(a.mixed_segment, None);
1384 }
1385
1386 #[test]
1387 fn one_loanword_keeps_plain_english() {
1388 let a = analyse(&s(
1391 "Please send José the invoice and the refund for his order today",
1392 ));
1393 assert_eq!(a.language.as_deref(), Some("en"));
1394 assert!(a.is_english);
1395 let a = analyse(&s(
1396 "I would like a refund for the résumé service I paid for twice this month",
1397 ));
1398 assert_eq!(a.language.as_deref(), Some("en"));
1399 assert!(a.is_english);
1400 }
1401
1402 #[test]
1403 fn loanword_rescue_is_bounded() {
1404 let a = analyse(&s(
1406 "We met at the café for a naïve chat about the résumé and the soirée plans",
1407 ));
1408 assert!(!a.is_english);
1409 let a = analyse(&s(
1411 "Jag har blivit debiterad två gånger och vill ha pengarna tillbaka nu",
1412 ));
1413 assert!(!a.is_english);
1414 }
1415
1416 #[test]
1417 fn per_leaf_detection_finds_hidden_non_english_field() {
1418 let big_en =
1422 "please help me with the account issue and the refund for the order ".repeat(80);
1423 let state = json!({
1424 "a_log": big_en,
1425 "z_customer": "Mein Konto wurde zweimal belastet und ich brauche dringend Hilfe",
1426 });
1427 let a = analyse(&state);
1428 assert_eq!(a.language.as_deref(), Some("de"));
1429 assert!(!a.is_english);
1430 assert_eq!(a.mixed_segment, None);
1431 }
1432}