1use crate::lang_tables::{NON_EN_DIACRITICS, SCRIPT_RANGES, STOPWORDS};
16use crate::pycompat::{py_isalpha, py_round, py_words_lowercase};
17use serde::Serialize;
18use serde_json::Value;
19
20const MAX_DETECTION_CHARS: usize = 4000;
21const MAX_DEPTH: usize = 6;
22
23#[derive(Clone, Debug, PartialEq, Serialize)]
25pub struct Detection {
26 pub script: String,
27 #[serde(serialize_with = "serialize_profile")]
32 pub script_profile: Vec<(String, f64)>,
33 pub language: Option<String>,
34 pub is_english: bool,
35 pub non_latin_fraction: f64,
36}
37
38fn serialize_profile<S>(
39 profile: &[(String, f64)],
40 serializer: S,
41) -> std::result::Result<S::Ok, S::Error>
42where
43 S: serde::Serializer,
44{
45 use serde::ser::SerializeMap;
46 let mut map = serializer.serialize_map(Some(profile.len()))?;
47 for (name, fraction) in profile {
48 map.serialize_entry(name, fraction)?;
49 }
50 map.end()
51}
52
53impl Detection {
54 fn latin_fraction(&self) -> f64 {
55 self.script_profile
56 .iter()
57 .find(|(name, _)| name == "latin")
58 .map_or(0.0, |(_, value)| *value)
59 }
60}
61
62fn iter_text<'a>(state: &'a Value, depth: usize, out: &mut Vec<&'a str>) {
64 if depth > MAX_DEPTH {
65 return;
66 }
67 match state {
68 Value::String(text) => out.push(text),
69 Value::Object(map) => {
71 for value in map.values() {
72 iter_text(value, depth + 1, out);
73 }
74 }
75 Value::Array(items) => {
76 for value in items {
77 iter_text(value, depth + 1, out);
78 }
79 }
80 _ => {}
81 }
82}
83
84pub fn state_text(state: &Value) -> String {
86 let mut leaves = Vec::new();
87 iter_text(state, 0, &mut leaves);
88 let joined = leaves.join(" ");
89 joined.chars().take(MAX_DETECTION_CHARS).collect()
91}
92
93#[inline]
95fn is_latin_codepoint(cp: u32) -> bool {
96 cp < 0x0250 || (0x1E00..=0x1EFF).contains(&cp)
97}
98
99fn count_scripts(text: &str, latin_first: bool) -> Vec<(String, usize)> {
104 let mut counts: Vec<(String, usize)> = if latin_first {
105 vec![("latin".to_string(), 0)]
106 } else {
107 Vec::new()
108 };
109 let mut latin = 0usize;
110
111 for c in text.chars() {
112 if !py_isalpha(c) {
113 continue;
114 }
115 let cp = c as u32;
116 if is_latin_codepoint(cp) {
117 if latin_first {
118 counts[0].1 += 1;
119 } else {
120 latin += 1;
121 }
122 continue;
123 }
124 for (name, ranges) in SCRIPT_RANGES {
125 if ranges.iter().any(|(lo, hi)| (*lo..=*hi).contains(&cp)) {
126 match counts.iter_mut().find(|(key, _)| key == name) {
127 Some(entry) => entry.1 += 1,
128 None => counts.push((name.to_string(), 1)),
129 }
130 break;
131 }
132 }
133 }
134
135 if !latin_first {
136 match counts.iter_mut().find(|(key, _)| key == "latin") {
139 Some(entry) => entry.1 = latin,
140 None => counts.push(("latin".to_string(), latin)),
141 }
142 }
143 counts
144}
145
146pub fn detect_script(text: &str) -> String {
148 let counts = count_scripts(text, false);
149 let total: usize = counts.iter().map(|(_, n)| *n).sum();
150 if total == 0 {
151 return "unknown".to_string();
152 }
153 counts
155 .into_iter()
156 .max_by_key(|(_, n)| *n)
157 .map(|(name, _)| name)
158 .unwrap_or_else(|| "unknown".to_string())
159}
160
161pub fn script_profile(text: &str) -> Vec<(String, f64)> {
163 let counts = count_scripts(text, true);
164 let total: usize = counts.iter().map(|(_, n)| *n).sum();
165 if total == 0 {
166 return Vec::new();
167 }
168 counts
169 .into_iter()
170 .filter(|(_, n)| *n > 0)
171 .map(|(name, n)| (name, n as f64 / total as f64))
172 .collect()
173}
174
175pub fn guess_latin_language(text: &str) -> Option<String> {
177 let words = py_words_lowercase(text);
178 if words.len() < 4 {
179 return None;
180 }
181
182 let scores: Vec<(&str, usize)> = STOPWORDS
183 .iter()
184 .map(|(code, stopwords)| {
185 let hits = words
186 .iter()
187 .filter(|word| stopwords.binary_search(&word.as_str()).is_ok())
188 .count();
189 (*code, hits)
190 })
191 .collect();
192
193 let lowered = text.to_lowercase();
194 let diacritics = lowered
195 .chars()
196 .filter(|c| NON_EN_DIACRITICS.contains(*c))
197 .count();
198 let diacritic_rate = diacritics as f64 / std::cmp::max(1, lowered.chars().count()) as f64;
199
200 let english = scores
201 .iter()
202 .find(|(code, _)| *code == "en")
203 .map_or(0, |(_, hits)| *hits);
204
205 let best = scores
207 .iter()
208 .filter(|(code, _)| *code != "en")
209 .copied()
210 .fold(None::<(&str, usize)>, |acc, item| match acc {
211 Some((_, best)) if best >= item.1 => acc,
212 _ => Some(item),
213 });
214 let (best_lang, best_score) = match best {
215 Some((lang, score)) => (Some(lang), score),
216 None => (None, 0),
217 };
218
219 let fallback = || {
220 if english > 0 {
221 Some("en".to_string())
222 } else {
223 None
224 }
225 };
226
227 if best_score == 0 && diacritic_rate < 0.02 {
228 return fallback();
229 }
230 if let Some(lang) = best_lang {
231 if best_score >= std::cmp::max(2, english + 2) {
233 return Some(lang.to_string());
234 }
235 if diacritic_rate >= 0.04 && best_score >= english {
236 return Some(lang.to_string());
237 }
238 }
239 fallback()
240}
241
242pub fn analyse(state: &Value) -> Detection {
244 let text = state_text(state);
245 let profile = script_profile(&text);
246 let script = detect_script(&text);
247
248 let mut detection = Detection {
249 script: script.clone(),
250 script_profile: profile,
251 language: None,
252 is_english: true,
253 non_latin_fraction: 0.0,
254 };
255
256 if script == "unknown" {
257 return detection;
258 }
259
260 let non_latin = if detection.script_profile.is_empty() {
261 0.0
262 } else {
263 py_round(1.0 - detection.latin_fraction(), 4)
264 };
265
266 if script != "latin" {
267 detection.is_english = false;
268 detection.non_latin_fraction = non_latin;
269 return detection;
270 }
271
272 let language = guess_latin_language(&text);
273 detection.is_english = matches!(language.as_deref(), None | Some("en"));
274 detection.language = language;
275 detection.non_latin_fraction = non_latin;
276 detection
277}
278
279pub fn is_english(state: &Value) -> bool {
281 analyse(state).is_english
282}
283
284#[cfg(test)]
285mod tests {
286 use super::*;
287 use serde_json::json;
288
289 #[test]
290 fn detects_scripts() {
291 assert_eq!(detect_script("hello there"), "latin");
292 assert_eq!(detect_script("Мой счёт"), "cyrillic");
293 assert_eq!(detect_script("こんにちは"), "kana");
294 assert_eq!(detect_script("मेरा खाता"), "devanagari");
295 assert_eq!(detect_script("12345 !!!"), "unknown");
296 }
297
298 #[test]
299 fn combining_marks_do_not_count_as_letters() {
300 let profile = script_profile("\u{0915}\u{093E}");
303 assert_eq!(profile, vec![("devanagari".to_string(), 1.0)]);
304 }
305
306 #[test]
307 fn english_text_routes_to_english() {
308 let detection = analyse(&json!("I was charged twice and would like a refund please"));
309 assert_eq!(detection.script, "latin");
310 assert!(detection.is_english);
311 }
312
313 #[test]
314 fn german_text_is_not_english() {
315 let detection = analyse(&json!(
316 "Mein Konto wurde zweimal belastet und ich möchte eine Rückerstattung"
317 ));
318 assert_eq!(detection.script, "latin");
319 assert!(!detection.is_english);
320 assert_eq!(detection.language.as_deref(), Some("de"));
321 }
322
323 #[test]
324 fn non_latin_reports_a_fraction() {
325 let detection = analyse(&json!("Мой счёт был дважды списан"));
326 assert!(!detection.is_english);
327 assert!(detection.non_latin_fraction > 0.9);
328 }
329
330 #[test]
331 fn nested_states_are_flattened() {
332 let detection = analyse(&json!({"message": {"body": ["こんにちは", 42]}}));
333 assert_eq!(detection.script, "kana");
334 }
335
336 #[test]
337 fn short_input_stays_undecided() {
338 assert_eq!(guess_latin_language("hi there"), None);
339 }
340}