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 "how", "why", "when", "where", "who", "whom", "does", "done", "doing", "been",
102 "being", "could", "should", "might", "must", "shall", "doesn", "didn", "isn",
103 "aren", "wasn", "weren", "haven", "hasn", "couldn", "wouldn", "won", "dont",
104 "wont", "didnt", "doesnt", "isnt", "wasnt", "havent", "ive", "youre", "thats",
105 "whats", "any", "some", "many", "much", "every", "each", "these", "those", "both",
106 "few", "they", "them", "she", "him", "his", "our", "your", "yours", "into", "onto",
107 "between", "after", "before", "through", "during", "without", "within", "down",
108 "if", "nor", "than", "then", "because", "while", "until", "here", "only", "very",
109 "too", "ever", "never", "always", "already", "yet",
110 ]
111 .into_iter()
112 .collect(),
113 );
114 m.insert(
115 "fr",
116 [
117 "le", "la", "les", "des", "une", "est", "pour", "dans", "que", "qui", "avec",
118 "sur", "pas", "plus", "nous", "vous", "être", "cette", "mais", "sont", "ont",
119 "aux", "ce", "et", "du", "au", "ou", "je", "tu", "il", "elle", "ils", "elles",
120 "mon", "ton", "ma", "ta", "sa", "mes", "tes", "ses", "ces", "deux", "trois",
121 "très", "bien", "tout", "tous", "toute", "fait", "veux", "veut", "peux", "peut",
122 "dois", "doit", "merci", "bonjour", "jour", "jours", "mois", "fois", "quand",
123 "comment", "pourquoi", "alors", "donc",
124 ]
125 .into_iter()
126 .collect(),
127 );
128 m.insert(
129 "de",
130 [
131 "der", "die", "das", "und", "ist", "ein", "eine", "den", "dem", "nicht", "mit",
132 "für", "auf", "von", "zu", "sich", "auch", "werden", "wurde", "haben", "sind",
133 "oder", "aber", "ich", "wir", "mir", "mich", "dir", "dich", "uns", "mein", "meine",
134 "meinen", "meinem", "meiner", "diese", "dieser", "diesen", "dieses", "einen",
135 "einem", "einer", "wie", "wo", "wann", "welche", "im", "zum", "zur", "aus", "bei",
136 "nach", "noch", "bitte", "heute", "jetzt", "kann", "kannst", "habe", "gibt",
137 "wird",
138 "in", "was",
140 ]
141 .into_iter()
142 .collect(),
143 );
144 m.insert(
145 "es",
146 [
147 "el", "los", "las", "que", "por", "con", "para", "una", "es", "se", "del", "como",
148 "pero", "son", "está", "este", "esta", "todo", "más", "muy", "hay", "sus", "la",
149 "un", "y", "al", "lo", "le", "les", "su", "mi", "tu", "nos", "ni", "dos", "tres",
150 "fue", "fueron", "ser", "tiene", "tienen", "tengo", "puede", "pueden", "quiero",
151 "necesito", "hemos", "han", "sobre", "entre", "cuando", "donde", "porque",
152 "aunque", "también", "ya", "eso", "esto", "esa", "ese", "nada", "algo", "aquí",
153 "hoy", "gracias",
154 ]
155 .into_iter()
156 .collect(),
157 );
158 m.insert(
159 "pt",
160 [
161 "os", "as", "que", "em", "um", "uma", "para", "com", "não", "é", "se", "do", "da",
162 "dos", "das", "mas", "são", "está", "este", "esta", "muito", "pelo", "pela", "o",
163 "e", "na", "nas", "nos", "ao", "aos", "por", "foi", "era", "ser", "sou", "tem",
164 "tenho", "pode", "podem", "quero", "preciso", "eu", "meu", "minha", "seu", "sua",
165 "isso", "isto", "aqui", "ali", "como", "quando", "onde", "porque", "mais", "já",
166 "ainda", "agora", "hoje", "ontem", "dois", "três", "tudo", "nada", "obrigado",
167 "olá", "você", "vocês", "voce", "voces", "vc", "vcs", "nao", "sao", "ja", "até",
168 "tá", "pra", "gostaria", "obrigada", "também", "tambem", "estou", "estamos",
169 "meus", "minhas", "nosso", "nossa", "consigo", "cadê", "boa", "tarde", "noite",
170 "depois", "antes", "então", "entao", "ninguém", "ninguem", "alguém", "alguem",
171 "nenhum", "nenhuma", "estava", "ficou", "fiz", "deu",
172 ]
173 .into_iter()
174 .collect(),
175 );
176 m.insert(
177 "it",
178 [
179 "il", "lo", "gli", "che", "di", "per", "con", "non", "è", "si", "del", "della",
180 "sono", "questo", "questa", "anche", "come", "più", "sono", "nella", "alla", "la",
181 "le", "un", "uno", "una", "e", "ed", "o", "da", "su", "tra", "fra", "mi", "ci",
182 "ne", "ho", "hai", "ha", "abbiamo", "avete", "hanno", "era", "stato", "stata",
183 "devo", "deve", "devono", "voglio", "vorrei", "mio", "mia", "tuo", "sua", "quando",
184 "dove", "perche", "molto", "poco", "sempre", "mai", "già", "ancora", "adesso",
185 "oggi", "ieri", "grazie", "ciao", "scusa", "nel", "nell", "negli", "sul", "sulla",
186 "sulle", "dal", "dalla", "dallo", "dagli", "dei", "delle", "dello", "degli",
187 "agli", "alle", "col",
188 ]
189 .into_iter()
190 .collect(),
191 );
192 m.insert(
193 "nl",
194 [
195 "het", "een", "van", "is", "op", "te", "dat", "niet", "met", "voor", "zijn", "aan",
196 "door", "maar", "ook", "worden", "deze", "naar", "wordt",
197 ]
198 .into_iter()
199 .collect(),
200 );
201 m.insert(
206 "sv",
207 [
208 "jag",
209 "är",
210 "och",
211 "inte",
212 "att",
213 "från",
214 "till",
215 "behöver",
216 "får",
217 "skulle",
218 "ska",
219 "vill",
220 "måste",
221 "också",
222 "dessa",
223 "detta",
224 "säger",
225 "upp",
226 "utan",
227 "mitt",
228 "min",
229 "om",
230 "kommer",
231 "här",
232 "två",
233 "vi",
234 "nästa",
235 "gör",
236 "göra",
237 "hjälp",
238 "hjälpa",
239 "mig",
240 "återbetalning",
241 "återbetala",
242 "faktura",
243 "gång",
244 "gånger",
245 "hittar",
246 "inställningen",
247 "inställningarna",
248 "lösenord",
249 "när",
250 "öppnar",
251 "spårningen",
252 "aterbetalning",
253 "aterbetala",
254 "behover",
255 "fel",
256 "ganger",
257 "hjalp",
258 "hjalpa",
259 "installningen",
260 "installningarna",
261 "kraschar",
262 "kvittot",
263 "losenord",
264 "nar",
265 "oppnar",
266 "paket",
267 "skicka",
268 "sparningen",
269 "tva",
270 "uppdaterats",
271 "blivit",
272 "debiterade",
273 "appen",
274 ]
275 .into_iter()
276 .collect(),
277 );
278 m.insert(
279 "ro",
280 [
281 "și", "să", "este", "sunt", "care", "pentru", "din", "dar", "după", "până", "fără",
282 "ale", "lui", "în", "fost", "acum", "vreau", "trebuie", "foarte", "acest",
283 "această", "acesta", "aceasta", "mi", "ți", "vă", "nu",
284 ]
285 .into_iter()
286 .collect(),
287 );
288 m.insert(
289 "bn",
290 [
291 "ami",
292 "amar",
293 "amake",
294 "amra",
295 "amader",
296 "apni",
297 "apnar",
298 "apnake",
299 "apnara",
300 "tumi",
301 "tomar",
302 "tomake",
303 "tomra",
304 "tader",
305 "ota",
306 "eita",
307 "oita",
308 "ekta",
309 "ei",
310 "oi",
311 "ki",
312 "keno",
313 "kivabe",
314 "kibhabe",
315 "kothay",
316 "kokhon",
317 "kobe",
318 "koto",
319 "kintu",
320 "jodi",
321 "tahole",
322 "ar",
323 "theke",
324 "jonno",
325 "sathe",
326 "shathe",
327 "diye",
328 "niye",
329 "moddhe",
330 "kore",
331 "korte",
332 "korchi",
333 "korsi",
334 "korbo",
335 "korechi",
336 "koreche",
337 "korun",
338 "koren",
339 "korlam",
340 "hobe",
341 "hoyeche",
342 "hoise",
343 "hocche",
344 "hoyni",
345 "chai",
346 "chaina",
347 "lagbe",
348 "parchi",
349 "parbo",
350 "parchina",
351 "peyechi",
352 "paini",
353 "dite",
354 "dilam",
355 "diyechi",
356 "nai",
357 "khub",
358 "onek",
359 "ekhon",
360 "akhon",
361 "ekhono",
362 "abar",
363 "ekbar",
364 "duibar",
365 "ajke",
366 "kalke",
367 "taka",
368 "bhalo",
369 "valo",
370 "kharap",
371 "shomossa",
372 "somossa",
373 "dhonnobad",
374 "bhai",
375 "shob",
376 "keu",
377 "kichu",
378 "bolte",
379 "bolun",
380 "parben",
381 "asbe",
382 "jabe",
383 "pabo",
384 "ferot",
385 "dorkar",
386 "hoye",
387 "geche",
388 "gese",
389 ]
390 .into_iter()
391 .collect(),
392 );
393 m.insert(
394 "az",
395 [
396 "və", "ve", "bir", "bu", "üçün", "ucun", "ilə", "ile", "olan", "olub", "olmasa",
397 "var", "yox", "yoxdur", "mən", "sən", "biz", "siz", "onlar", "daha", "çox", "cox",
398 "hər", "nə", "kimi", "görə", "sonra", "əgər", "eger", "deyil", "lakin", "amma",
399 "ancaq", "artıq", "artiq", "də", "isə", "həm", "yalnız", "yalniz",
400 ]
401 .into_iter()
402 .collect(),
403 );
404 m
405 })
406}
407
408const DIACRITICS: &str = concat!(
410 "àâäãáåçéèêëíìîïñóòôöõøúùûüýÿßæœ", "ăâîșțşţ", "ąćęłńśźż", "čďěňřšťůž", "őű", "ğı", "āēģīķļņūž", "đ", "ə", );
420
421fn non_en_diacritics() -> &'static HashSet<char> {
422 static S: OnceLock<HashSet<char>> = OnceLock::new();
423 S.get_or_init(|| DIACRITICS.chars().collect())
424}
425
426fn short_swedish_words() -> &'static HashSet<&'static str> {
431 static S: OnceLock<HashSet<&'static str>> = OnceLock::new();
432 S.get_or_init(|| {
433 [
434 "åtkomst",
435 "atkomst",
436 "lösenord",
437 "losenord",
438 "fakturan",
439 "betalningen",
440 "inloggningen",
441 "glömt",
442 "glomt",
443 "behöver",
444 "behover",
445 "återbetalning",
446 "aterbetalning",
447 "kvitto",
448 "kvittot",
449 "spårningen",
450 "sparningen",
451 "inställningen",
452 "installningen",
453 "felmeddelande",
454 "abonnemanget",
455 ]
456 .into_iter()
457 .collect()
458 })
459}
460
461fn nordic_overlap_words() -> &'static HashSet<&'static str> {
466 static S: OnceLock<HashSet<&'static str>> = OnceLock::new();
467 S.get_or_init(|| {
468 [
469 "hej", "ja", "nej", "jo", "tack", "mig", "min", "om", "kommer", "får", "skulle", "vi",
470 ]
471 .into_iter()
472 .collect()
473 })
474}
475
476fn shared_words() -> &'static HashSet<&'static str> {
478 static S: OnceLock<HashSet<&'static str>> = OnceLock::new();
479 S.get_or_init(|| {
480 let mut counts: HashMap<&'static str, usize> = HashMap::new();
481 for words in stop().values() {
482 for w in words {
483 *counts.entry(*w).or_insert(0) += 1;
484 }
485 }
486 let mut shared: HashSet<&'static str> = counts
487 .into_iter()
488 .filter(|(_, n)| *n > 1)
489 .map(|(w, _)| w)
490 .collect();
491 shared.extend(nordic_overlap_words().iter().copied());
492 shared
493 })
494}
495
496fn en_only_words() -> &'static HashSet<&'static str> {
499 static S: OnceLock<HashSet<&'static str>> = OnceLock::new();
500 S.get_or_init(|| {
501 let en = &stop()["en"];
502 let shared = shared_words();
503 en.iter().copied().filter(|w| !shared.contains(w)).collect()
504 })
505}
506
507fn en_collision_words() -> &'static HashSet<&'static str> {
516 static S: OnceLock<HashSet<&'static str>> = OnceLock::new();
517 S.get_or_init(|| {
518 const CANDIDATES: [&str; 8] = ["come", "son", "do", "care", "todo", "im", "per", "plus"];
519 let mut non_en: HashSet<&'static str> = HashSet::new();
520 for (lg, sw) in stop() {
521 if *lg == "en" {
522 continue;
523 }
524 non_en.extend(sw.iter().copied());
525 }
526 CANDIDATES
527 .into_iter()
528 .filter(|w| non_en.contains(w))
529 .collect()
530 })
531}
532
533fn english_rescued_by_words(words: &[&str], diac_rate: f64) -> bool {
542 if diac_rate >= ENGLISH_RESCUE_DIACRITIC_RATE {
543 return false;
544 }
545 let word_set: HashSet<&str> = words.iter().copied().collect();
546 let en_only = en_only_words();
547 if word_set.iter().filter(|w| en_only.contains(*w)).count() < 2 {
548 return false;
549 }
550 let diac = non_en_diacritics();
551 word_set
552 .iter()
553 .filter(|w| w.chars().any(|ch| diac.contains(&ch)))
554 .count()
555 <= 1
556}
557
558fn word_re() -> &'static Regex {
559 static R: OnceLock<Regex> = OnceLock::new();
560 R.get_or_init(|| Regex::new(r"[^\W\d_]+").unwrap())
561}
562
563fn identifier_re() -> &'static Regex {
568 static R: OnceLock<Regex> = OnceLock::new();
569 R.get_or_init(|| Regex::new(r"[\w-]*(?:[.@][\w-]+)+").unwrap())
570}
571
572fn is_combining(ch: char) -> bool {
574 matches!(
575 get_general_category(ch),
576 GeneralCategory::NonspacingMark
577 | GeneralCategory::SpacingMark
578 | GeneralCategory::EnclosingMark
579 )
580}
581
582fn is_latin_detect(cp: u32) -> bool {
584 cp < 0x02B0
585 || (0x1E00..=0x1EFF).contains(&cp)
586 || (0xFF21..=0xFF3A).contains(&cp)
587 || (0xFF41..=0xFF5A).contains(&cp)
588}
589
590fn is_latin_script_of(cp: u32) -> bool {
592 cp < 0x0250
593 || (0x1E00..=0x1EFF).contains(&cp)
594 || (0xFF21..=0xFF3A).contains(&cp)
595 || (0xFF41..=0xFF5A).contains(&cp)
596}
597
598fn in_ranges(cp: u32, ranges: &[(u32, u32)]) -> bool {
599 ranges.iter().any(|&(lo, hi)| lo <= cp && cp <= hi)
600}
601
602fn round4(x: f64) -> f64 {
604 round_half_even(x * 10000.0) / 10000.0
605}
606
607fn round_half_even(x: f64) -> f64 {
609 let floor = x.floor();
610 let diff = x - floor;
611 if diff < 0.5 {
612 floor
613 } else if diff > 0.5 {
614 floor + 1.0
615 } else {
616 if (floor as i64) % 2 == 0 {
618 floor
619 } else {
620 floor + 1.0
621 }
622 }
623}
624
625fn iter_text(v: &Value, depth: usize, out: &mut Vec<String>) {
628 if depth > 6 {
629 return;
630 }
631 match v {
632 Value::Null => {}
633 Value::String(s) => out.push(s.clone()),
634 Value::Array(a) => {
635 for x in a {
636 iter_text(x, depth + 1, out);
637 }
638 }
639 Value::Object(m) => {
640 for x in m.values() {
641 iter_text(x, depth + 1, out);
642 }
643 }
644 _ => {}
645 }
646}
647
648pub fn state_text(state: &Value, max_chars: usize) -> String {
650 let mut leaves: Vec<String> = Vec::new();
651 iter_text(state, 0, &mut leaves);
652 let mut parts: Vec<String> = Vec::new();
655 let mut budget = max_chars as isize;
656 for leaf in leaves {
657 if budget <= 0 {
658 break;
659 }
660 let leaf_len = leaf.chars().count() as isize;
661 if leaf_len > budget {
662 parts.push(leaf.chars().take(budget as usize).collect());
663 break;
664 }
665 parts.push(leaf);
666 budget -= leaf_len + 1;
668 }
669 parts.join(" ").chars().take(max_chars).collect()
670}
671
672pub fn detect_script(text: &str) -> String {
676 let mut counts: IndexMap<&'static str, i64> = IndexMap::new();
677 let mut latin: i64 = 0;
678 for ch in text.chars() {
679 if !ch.is_alphabetic() {
680 continue;
681 }
682 let cp = ch as u32;
683 if is_latin_detect(cp) {
684 latin += 1;
685 continue;
686 }
687 let mut found: Option<&'static str> = None;
688 for (name, ranges) in SCRIPT_RANGES {
689 if in_ranges(cp, ranges) {
690 found = Some(name);
691 break;
692 }
693 }
694 let key = found.unwrap_or("other");
695 *counts.entry(key).or_insert(0) += 1;
696 }
697 counts.insert("latin", latin);
698 let total: i64 = counts.values().sum();
699 if total == 0 {
700 return "unknown".to_string();
701 }
702 let mut best: &str = "";
704 let mut best_n: i64 = i64::MIN;
705 for (k, v) in &counts {
706 if *v > best_n {
707 best_n = *v;
708 best = k;
709 }
710 }
711 best.to_string()
712}
713
714pub fn script_profile(text: &str) -> IndexMap<String, f64> {
716 let mut counts: IndexMap<&'static str, i64> = IndexMap::new();
717 counts.insert("latin", 0);
718 for ch in text.chars() {
719 if !ch.is_alphabetic() {
720 continue;
721 }
722 let cp = ch as u32;
723 if is_latin_detect(cp) {
724 *counts.get_mut("latin").unwrap() += 1;
725 continue;
726 }
727 let mut found: Option<&'static str> = None;
728 for (name, ranges) in SCRIPT_RANGES {
729 if in_ranges(cp, ranges) {
730 found = Some(name);
731 break;
732 }
733 }
734 let key = found.unwrap_or("other");
735 *counts.entry(key).or_insert(0) += 1;
736 }
737 let total: i64 = counts.values().sum();
738 let mut out: IndexMap<String, f64> = IndexMap::new();
739 if total == 0 {
740 return out;
741 }
742 for (k, v) in &counts {
743 if *v != 0 {
744 out.insert((*k).to_string(), *v as f64 / total as f64);
745 }
746 }
747 out
748}
749
750fn script_of(ch: char) -> Option<&'static str> {
751 let cp = ch as u32;
752 if is_latin_script_of(cp) {
753 return None;
754 }
755 for (name, ranges) in SCRIPT_RANGES {
756 if in_ranges(cp, ranges) {
757 return Some(name);
758 }
759 }
760 None
761}
762
763fn non_latin_words(text: &str) -> Vec<String> {
765 let mut runs: Vec<String> = Vec::new();
766 let mut cur = String::new();
767 let mut script: Option<&'static str> = None;
768 for ch in text.chars() {
769 if is_combining(ch) {
770 continue;
771 }
772 let s = script_of(ch);
773 if s.is_some() && s == script {
774 cur.push(ch);
775 continue;
776 }
777 if !cur.is_empty() {
778 runs.push(std::mem::take(&mut cur));
779 }
780 match s {
781 Some(_) => {
782 cur = ch.to_string();
783 script = s;
784 }
785 None => {
786 cur = String::new();
787 script = None;
788 }
789 }
790 }
791 if !cur.is_empty() {
792 runs.push(cur);
793 }
794 runs.into_iter()
795 .filter(|w| {
796 w.chars().count() >= 2 && !w.chars().next().map(|c| c.is_uppercase()).unwrap_or(false)
797 })
798 .collect()
799}
800
801#[derive(Debug, Clone)]
805pub struct LatinProfile {
806 pub language: Option<String>,
807 pub english_hits: usize,
808 pub diacritic_rate: f64,
809 pub looks_non_english: bool,
810}
811
812pub fn latin_profile(text: &str) -> LatinProfile {
813 let stripped = identifier_re().replace_all(text, " ");
815 let normalized = stripped.replace('\u{0130}', "i").to_lowercase();
816 let words: Vec<&str> = word_re()
817 .find_iter(&normalized)
818 .map(|m| m.as_str())
819 .collect();
820
821 let lowered = text.to_lowercase();
822 let diac_set = non_en_diacritics();
823 let diac = lowered.chars().filter(|ch| diac_set.contains(ch)).count();
824 let len = lowered.chars().count();
825 let diac_rate = diac as f64 / (std::cmp::max(1, len) as f64);
826 let non_english = diac_rate >= NON_EN_DIACRITIC_RATE;
827
828 let word_set_all: HashSet<&str> = words.iter().copied().collect();
831 let en_only = en_only_words();
832 let nordic_overlap = word_set_all
833 .iter()
834 .any(|w| nordic_overlap_words().contains(*w))
835 && !word_set_all.iter().any(|w| en_only.contains(*w));
836
837 if words.len() > 1
839 && words.len() < 4
840 && word_set_all
841 .iter()
842 .any(|w| short_swedish_words().contains(*w))
843 {
844 return LatinProfile {
845 language: Some("sv".to_string()),
846 english_hits: 0,
847 diacritic_rate: diac_rate,
848 looks_non_english: non_english,
849 };
850 }
851
852 if words.len() < 4 {
853 return LatinProfile {
854 language: None,
855 english_hits: 0,
856 diacritic_rate: diac_rate,
857 looks_non_english: non_english || nordic_overlap,
858 };
859 }
860
861 let stop = stop();
862 let shared = shared_words();
863
864 let mut counts: HashMap<&str, i64> = HashMap::new();
867 for w in &words {
868 *counts.entry(*w).or_insert(0) += 1;
869 }
870 let collision = en_collision_words();
871 let mut scores: IndexMap<&'static str, i64> = IndexMap::new();
872 for (lg, sw) in stop {
873 let s: i64 = counts
874 .iter()
875 .filter(|(w, _)| sw.contains(**w))
876 .map(|(w, n)| if collision.contains(*w) { 1 } else { *n })
877 .sum();
878 scores.insert(lg, s);
879 }
880 let en = scores.get("en").copied().unwrap_or(0);
881
882 let mut best_lg: Option<&'static str> = None;
884 let mut best: i64 = 0;
885 for (lg, sw) in stop {
886 if *lg == "en" {
887 continue;
888 }
889 let has_evidence = word_set_all
890 .iter()
891 .any(|w| sw.contains(*w) && !shared.contains(*w));
892 if !has_evidence {
893 continue;
894 }
895 let s = *scores.get(lg).unwrap();
896 if s > best {
897 best = s;
898 best_lg = Some(lg);
899 }
900 }
901
902 let mut language: Option<String> = None;
903 if best_lg.is_some() && best >= std::cmp::max(2, en + 2) {
905 language = best_lg.map(|s| s.to_string());
907 } else if best_lg == Some("sv")
908 && word_set_all.contains("inte")
909 && word_set_all.contains("kan")
910 && matches!(
911 words.first().copied(),
912 Some("kan") | Some("jag") | Some("vi")
913 )
914 && en <= 1
915 {
916 language = best_lg.map(|s| s.to_string());
920 } else if best_lg.is_some() && non_english && best >= std::cmp::max(2, en) {
921 language = best_lg.map(|s| s.to_string());
923 } else if en > 0 && (!non_english || english_rescued_by_words(&words, diac_rate)) {
924 language = Some("en".to_string());
925 }
926
927 let looks_non_english = non_english || (language.is_none() && nordic_overlap);
928 LatinProfile {
929 language,
930 english_hits: en as usize,
931 diacritic_rate: diac_rate,
932 looks_non_english,
933 }
934}
935
936pub fn guess_latin_language(text: &str) -> Option<String> {
938 latin_profile(text).language
939}
940
941fn code_line_re() -> &'static Regex {
947 static R: OnceLock<Regex> = OnceLock::new();
948 R.get_or_init(|| Regex::new(r"[=;{}\[\]]|\w\(").unwrap())
949}
950
951fn joined_re() -> &'static Regex {
955 static R: OnceLock<Regex> = OnceLock::new();
956 R.get_or_init(|| Regex::new(r"[^\W_][._/\\][^\W_]").unwrap())
957}
958
959fn letter_run_re() -> &'static Regex {
963 static R: OnceLock<Regex> = OnceLock::new();
964 R.get_or_init(|| Regex::new(r"[^\W\d_]{2,}").unwrap())
965}
966
967fn is_all_upper(s: &str) -> bool {
970 let mut has_cased = false;
971 for c in s.chars() {
972 if c.is_lowercase() {
973 return false;
974 }
975 if c.is_uppercase() {
976 has_cased = true;
977 }
978 }
979 has_cased
980}
981
982fn non_latin_carries(non_latin: f64, n_non_latin: i64) -> bool {
985 non_latin >= NON_LATIN_FRACTION
986 || (non_latin >= NON_LATIN_MIN_FRACTION && n_non_latin >= NON_LATIN_MIN_LETTERS)
987}
988
989const SHOUTED_MIN_STOPWORD: usize = 4;
994const SHOUTED_MIN_WORD: usize = 5;
995
996fn is_shouted(text: &str) -> bool {
1000 text.chars().any(|c| c.is_uppercase()) && !text.chars().any(|c| c.is_lowercase())
1001}
1002
1003fn shouted_evidence(tokens: &[&str], lang: &str, diacritic_rate: f64) -> bool {
1007 if !tokens.iter().any(|t| t.chars().count() >= SHOUTED_MIN_WORD) {
1008 return false;
1009 }
1010 if diacritic_rate >= NON_EN_DIACRITIC_RATE {
1011 return true;
1012 }
1013 let Some(sw) = stop().get(lang) else {
1014 return false;
1015 };
1016 tokens.iter().any(|t| {
1017 let lw = t.to_lowercase();
1018 lw.chars().count() >= SHOUTED_MIN_STOPWORD && sw.contains(lw.as_str())
1019 })
1020}
1021
1022fn named_prose_language(segment: &str) -> Option<String> {
1030 if segment.trim().is_empty() || code_line_re().is_match(segment) {
1031 return None;
1032 }
1033 let joined = joined_re();
1034 let prose = segment
1035 .split_whitespace()
1036 .filter(|tok| !joined.is_match(tok))
1037 .collect::<Vec<_>>()
1038 .join(" ");
1039 let shouted = is_shouted(&prose);
1040 let prose = if !shouted {
1041 letter_run_re()
1042 .replace_all(&prose, |caps: ®ex::Captures| {
1043 let m = &caps[0];
1044 if is_all_upper(m) {
1045 " ".to_string()
1046 } else {
1047 m.to_string()
1048 }
1049 })
1050 .into_owned()
1051 } else {
1052 prose
1053 };
1054 let tokens: Vec<&str> = word_re().find_iter(&prose).map(|m| m.as_str()).collect();
1055 if tokens.len() < 4 {
1056 return None;
1057 }
1058 let prof = latin_profile(&prose);
1059 let lang = match prof.language {
1060 Some(ref l) if l != "en" => l.clone(),
1061 _ => return None,
1062 };
1063 if shouted && !shouted_evidence(&tokens, &lang, prof.diacritic_rate) {
1064 return None;
1065 }
1066 let sw = stop().get(lang.as_str())?;
1067 let lowered: HashSet<String> = tokens.iter().map(|w| w.to_lowercase()).collect();
1068 if lowered.iter().filter(|w| sw.contains(w.as_str())).count() < 2 {
1069 return None;
1070 }
1071 Some(lang)
1072}
1073
1074fn non_english_segment(state: &Value, max_chars: usize) -> Option<(String, String)> {
1082 let mut leaves: Vec<String> = Vec::new();
1083 iter_text(state, 0, &mut leaves);
1084 let mut seen = 0usize;
1085 for leaf in &leaves {
1086 for seg in leaf.split('\n') {
1087 if seen >= max_chars {
1088 return None;
1089 }
1090 let seg: String = seg.chars().take(max_chars - seen).collect();
1091 seen += seg.chars().count();
1092 if let Some(lang) = named_prose_language(&seg) {
1093 return Some((lang, seg.trim().to_string()));
1094 }
1095 }
1096 }
1097 None
1098}
1099
1100#[derive(Debug, Clone)]
1104pub struct Analysis {
1105 pub script: String,
1106 pub script_profile: IndexMap<String, f64>,
1107 pub language: Option<String>,
1108 pub is_english: bool,
1109 pub language_undecided: bool,
1110 pub diacritic_rate: f64,
1111 pub non_latin_fraction: f64,
1112 pub mixed_segment: Option<String>,
1114}
1115
1116fn analyse_text(text: &str) -> Analysis {
1120 let prof = script_profile(text);
1121 let mut script = detect_script(text);
1122
1123 let non_latin = if !prof.is_empty() {
1124 round4(1.0 - prof.get("latin").copied().unwrap_or(0.0))
1125 } else {
1126 0.0
1127 };
1128 let sum_alpha = text.chars().filter(|c| c.is_alphabetic()).count() as f64;
1129 let n_non_latin = round_half_even(non_latin * sum_alpha) as i64;
1130
1131 if script == "latin"
1132 && !non_latin_words(text).is_empty()
1133 && non_latin_carries(non_latin, n_non_latin)
1134 {
1135 let mut best: Option<&str> = None;
1137 let mut best_v = f64::NEG_INFINITY;
1138 for (k, v) in &prof {
1139 if k == "latin" {
1140 continue;
1141 }
1142 if *v > best_v {
1143 best_v = *v;
1144 best = Some(k);
1145 }
1146 }
1147 if let Some(b) = best {
1148 script = b.to_string();
1149 }
1150 }
1151
1152 if script == "unknown" {
1153 return Analysis {
1154 script: "unknown".to_string(),
1155 script_profile: prof,
1156 language: None,
1157 is_english: true,
1158 language_undecided: true,
1159 diacritic_rate: 0.0,
1160 non_latin_fraction: 0.0,
1161 mixed_segment: None,
1162 };
1163 }
1164 if script != "latin" {
1165 return Analysis {
1166 script,
1167 script_profile: prof,
1168 language: None,
1169 is_english: false,
1170 language_undecided: true,
1171 diacritic_rate: 0.0,
1172 non_latin_fraction: non_latin,
1173 mixed_segment: None,
1174 };
1175 }
1176
1177 let prof_lat = latin_profile(text);
1178 let mut lang = prof_lat.language.clone();
1179 if matches!(lang.as_deref(), Some(l) if l != "en") && !non_latin_carries(non_latin, n_non_latin)
1186 {
1187 if is_shouted(text) {
1188 let tokens: Vec<&str> = word_re().find_iter(text).map(|m| m.as_str()).collect();
1189 let ok = {
1190 let l = lang.as_deref().unwrap();
1191 shouted_evidence(&tokens, l, prof_lat.diacritic_rate)
1192 };
1193 if !ok {
1194 lang = None;
1195 }
1196 } else {
1197 let blank_run = |caps: ®ex::Captures| {
1202 let m = &caps[0];
1203 if is_all_upper(m) {
1204 " ".to_string()
1205 } else {
1206 m.to_string()
1207 }
1208 };
1209 let caps: Vec<&str> = letter_run_re()
1210 .find_iter(text)
1211 .map(|m| m.as_str())
1212 .filter(|m| is_all_upper(m))
1213 .collect();
1214 if !caps.is_empty() && !caps.iter().any(|c| c.chars().count() >= SHOUTED_MIN_WORD) {
1215 let blanked = letter_run_re().replace_all(text, blank_run);
1216 if blanked.as_ref() != text {
1217 lang = latin_profile(blanked.as_ref()).language;
1218 }
1219 }
1220 }
1221 }
1222 let undecided = lang.is_none();
1223 let english = lang.as_deref() == Some("en") || (undecided && !prof_lat.looks_non_english);
1224 Analysis {
1225 script: "latin".to_string(),
1226 script_profile: prof,
1227 language: lang,
1228 is_english: english,
1229 language_undecided: undecided,
1230 diacritic_rate: round4(prof_lat.diacritic_rate),
1231 non_latin_fraction: non_latin,
1232 mixed_segment: None,
1233 }
1234}
1235
1236fn leaf_non_english(leaf: &str) -> Option<Analysis> {
1245 let mut best: Option<Analysis> = None;
1246 let mut best_n: i64 = -1;
1247 for line in leaf.split('\n') {
1248 if line.chars().count() < 7 {
1254 continue;
1255 }
1256 let sample: String = line.chars().take(4000).collect();
1257 if sample.trim().is_empty() || code_line_re().is_match(&sample) {
1258 continue;
1259 }
1260 let det = analyse_text(&sample);
1261 if det.is_english {
1262 continue;
1263 }
1264 let named = matches!(det.language.as_deref(), Some(l) if l != "en")
1270 && named_prose_language(&sample).is_some();
1271 if !named {
1272 if det.script != "latin" && det.script != "unknown" {
1273 let n_alpha = sample.chars().filter(|c| c.is_alphabetic()).count() as i64;
1274 if non_latin_words(&sample).is_empty() || n_alpha < NON_LATIN_MIN_LETTERS {
1275 continue;
1276 }
1277 } else if !(det.diacritic_rate >= NON_EN_DIACRITIC_RATE
1278 && word_re().find_iter(&sample).count() >= 4)
1279 {
1280 continue;
1281 }
1282 }
1283 let n_alpha = sample.chars().filter(|c| c.is_alphabetic()).count() as i64;
1284 if n_alpha > best_n {
1285 best_n = n_alpha;
1286 best = Some(det);
1287 }
1288 }
1289 best
1290}
1291
1292pub fn analyse(state: &Value) -> Analysis {
1293 let mut result = analyse_text(&state_text(state, 4000));
1294 if result.script == "latin" && result.is_english {
1299 let mut leaves: Vec<String> = Vec::new();
1300 iter_text(state, 0, &mut leaves);
1301 if leaves.len() > 1 || leaves.iter().any(|l| l.contains('\n')) {
1302 if let Some((seg_lang, seg)) = non_english_segment(state, 4000) {
1303 result.language = Some(seg_lang);
1304 result.is_english = false;
1305 result.language_undecided = false;
1306 result.mixed_segment = Some(seg);
1307 }
1308 }
1309 }
1310 if matches!(state, Value::String(_) | Value::Null) || !result.is_english {
1314 return result;
1315 }
1316 let mut leaves: Vec<String> = Vec::new();
1317 iter_text(state, 0, &mut leaves);
1318 let mut best: Option<Analysis> = None;
1319 let mut best_n: i64 = -1;
1320 for leaf in &leaves {
1321 let Some(det) = leaf_non_english(leaf) else {
1322 continue;
1323 };
1324 let capped: String = leaf.chars().take(4000).collect();
1325 let n_alpha = capped.chars().filter(|c| c.is_alphabetic()).count() as i64;
1326 if n_alpha > best_n {
1327 best_n = n_alpha;
1328 best = Some(det);
1329 }
1330 }
1331 if let Some(det) = best {
1332 result.language = det.language;
1333 result.is_english = false;
1334 result.language_undecided = det.language_undecided;
1335 }
1336 result
1337}
1338
1339pub fn is_english(state: &Value) -> bool {
1341 analyse(state).is_english
1342}
1343
1344#[cfg(test)]
1345mod tests {
1346 use super::*;
1347 use serde_json::json;
1348
1349 fn s(t: &str) -> Value {
1350 Value::String(t.to_string())
1351 }
1352
1353 #[test]
1354 fn detects_devanagari_as_non_latin() {
1355 assert_eq!(detect_script("मुझसे दो बार शुल्क लिया गया"), "devanagari");
1356 }
1357
1358 #[test]
1359 fn routes_english_latin_to_english() {
1360 let a = analyse(&s("Please refund the duplicate charge"));
1361 assert_eq!(a.script, "latin");
1362 assert!(a.is_english);
1363 assert_eq!(a.language.as_deref(), Some("en"));
1364 }
1365
1366 #[test]
1367 fn routes_german_latin_to_non_english() {
1368 assert!(!is_english(&s("Der Kunde wurde zweimal belastet")));
1369 }
1370
1371 #[test]
1372 fn collision_word_repeat_counts_once() {
1373 assert_eq!(
1377 latin_profile("do do do do please refund me")
1378 .language
1379 .as_deref(),
1380 Some("en")
1381 );
1382 assert_eq!(
1384 latin_profile("reduzieren der helligkeit der lichter")
1385 .language
1386 .as_deref(),
1387 Some("de")
1388 );
1389 }
1390
1391 #[test]
1392 fn detects_french_multilingual_signals() {
1393 let a = analyse(&s("Je voudrais un remboursement pour la commande"));
1394 assert!(!a.is_english);
1395 assert_eq!(a.language.as_deref(), Some("fr"));
1396 }
1397
1398 #[test]
1399 fn unknown_no_letters_is_english_and_undecided() {
1400 let a = analyse(&s("123 !!!"));
1401 assert_eq!(a.script, "unknown");
1402 assert!(a.is_english);
1403 assert!(a.language_undecided);
1404 }
1405
1406 #[test]
1407 fn cjk_han_is_not_english() {
1408 let a = analyse(&s("请退还重复的费用"));
1409 assert_eq!(a.script, "han");
1410 assert!(!a.is_english);
1411 }
1412
1413 #[test]
1414 fn hindi_devanagari_is_not_english() {
1415 let a = analyse(&s("मुझसे दो बार शुल्क लिया गया"));
1416 assert_eq!(a.script, "devanagari");
1417 assert!(!a.is_english);
1418 }
1419
1420 #[test]
1421 fn romanized_bangla_detected() {
1422 let a = analyse(&s("ami ekta refund chai amar order er jonno"));
1423 assert_eq!(a.script, "latin");
1424 assert_eq!(a.language.as_deref(), Some("bn"));
1425 assert!(!a.is_english);
1426 }
1427
1428 #[test]
1429 fn url_only_not_misdetected_as_foreign() {
1430 let a = analyse(&s("acme.com foo.com bar.com test.com"));
1432 assert_eq!(a.language, None);
1433 assert!(a.is_english);
1434 }
1435
1436 #[test]
1437 fn all_caps_acronym_line_is_not_foreign_prose() {
1438 let a = analyse(&s("MON DES EST LA"));
1442 assert_eq!(a.language, None);
1443 assert!(a.is_english);
1444 let a = analyse(&s("STORES LOS ANGELES LAS VEGAS EL PASO CLOSED"));
1445 assert_eq!(a.language, None);
1446 assert!(a.is_english);
1447 }
1448
1449 #[test]
1450 fn shouted_line_with_diacritics_is_kept_foreign() {
1451 assert!(!analyse(&s("WIE SPÄT IST ES IN KÖLN")).is_english);
1453 assert!(is_english(&s("WIE SPAET IST ES IN KOELN")));
1456 }
1457
1458 #[test]
1459 fn emphasis_capitals_do_not_erase_the_language() {
1460 assert_eq!(
1463 analyse(&s("sag mir das HEUTIGE DATUM")).language.as_deref(),
1464 Some("de")
1465 );
1466 assert_eq!(
1467 analyse(&s("quiero cancelar mi PEDIDO POR FAVOR"))
1468 .language
1469 .as_deref(),
1470 Some("es")
1471 );
1472 }
1473
1474 #[test]
1475 fn english_line_above_acronym_line_reads_english() {
1476 let a = analyse(&s("Please refund my order\nMON DES EST LA"));
1479 assert!(a.is_english);
1480 assert_eq!(a.language.as_deref(), Some("en"));
1481 }
1482
1483 #[test]
1484 fn script_profile_latin_first_and_fractions() {
1485 let p = script_profile("abc देव");
1486 let keys: Vec<&String> = p.keys().collect();
1488 assert_eq!(keys[0], "latin");
1489 assert!(p.contains_key("devanagari"));
1490 let sum: f64 = p.values().sum();
1491 assert!((sum - 1.0).abs() < 1e-9);
1492 }
1493
1494 #[test]
1495 fn nested_state_is_flattened() {
1496 let state = json!({"a": "Please refund", "b": ["the", "duplicate", "charge"]});
1497 assert!(is_english(&state));
1498 }
1499
1500 #[test]
1501 fn round4_is_half_to_even() {
1502 assert_eq!(round_half_even(2.5), 2.0);
1503 assert_eq!(round_half_even(3.5), 4.0);
1504 }
1505
1506 #[test]
1507 fn mixed_field_makes_state_non_english() {
1508 let state = json!({
1511 "customer": "Eu preciso de ajuda com a minha conta pois fui cobrado duas vezes",
1512 "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"
1513 });
1514 let a = analyse(&state);
1515 assert_eq!(a.language.as_deref(), Some("pt"));
1516 assert!(!a.is_english);
1517 assert_eq!(
1518 a.mixed_segment.as_deref(),
1519 Some("Eu preciso de ajuda com a minha conta pois fui cobrado duas vezes")
1520 );
1521 }
1522
1523 #[test]
1524 fn mixed_segment_ignores_code_lines() {
1525 let a = analyse(&s(
1528 "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",
1529 ));
1530 assert!(a.is_english);
1531 assert_eq!(a.language.as_deref(), Some("en"));
1532 assert_eq!(a.mixed_segment, None);
1533 }
1534
1535 #[test]
1536 fn single_line_never_reports_mixed_segment() {
1537 let a = analyse(&s("Please refund the duplicate charge on my account today"));
1539 assert_eq!(a.mixed_segment, None);
1540 }
1541
1542 #[test]
1543 fn one_loanword_keeps_plain_english() {
1544 let a = analyse(&s(
1547 "Please send José the invoice and the refund for his order today",
1548 ));
1549 assert_eq!(a.language.as_deref(), Some("en"));
1550 assert!(a.is_english);
1551 let a = analyse(&s(
1552 "I would like a refund for the résumé service I paid for twice this month",
1553 ));
1554 assert_eq!(a.language.as_deref(), Some("en"));
1555 assert!(a.is_english);
1556 }
1557
1558 #[test]
1559 fn loanword_rescue_is_bounded() {
1560 let a = analyse(&s(
1562 "We met at the café for a naïve chat about the résumé and the soirée plans",
1563 ));
1564 assert!(!a.is_english);
1565 let a = analyse(&s(
1567 "Jag har blivit debiterad två gånger och vill ha pengarna tillbaka nu",
1568 ));
1569 assert!(!a.is_english);
1570 }
1571
1572 #[test]
1573 fn per_leaf_detection_finds_hidden_non_english_field() {
1574 let big_en =
1578 "please help me with the account issue and the refund for the order ".repeat(80);
1579 let state = json!({
1580 "a_log": big_en,
1581 "z_customer": "Mein Konto wurde zweimal belastet und ich brauche dringend Hilfe",
1582 });
1583 let a = analyse(&state);
1584 assert_eq!(a.language.as_deref(), Some("de"));
1585 assert!(!a.is_english);
1586 assert_eq!(a.mixed_segment, None);
1587 }
1588}