1use fancy_regex::Regex;
7use serde_json::{Value, json};
8use std::collections::{HashMap, HashSet};
9use std::sync::LazyLock;
10use unicode_normalization::UnicodeNormalization;
11
12const MONTH_NAMES: [&str; 12] = [
13 "january janvier januar enero gennaio janeiro januari stycznia styczen января",
14 "february fevrier februar febrero febbraio fevereiro februari lutego luty февраля",
15 "march mars marz marzo marco maart marca марта",
16 "april avril abril aprile kwietnia kwiecien апреля",
17 "may mai mayo maggio maio mei maj maja мая",
18 "june juin juni junio giugno junho czerwca czerwiec июня",
19 "july juillet juli julio luglio julho lipca lipiec июля",
20 "august aout agosto augustus augusti sierpnia sierpien августа",
21 "september septembre septiembre settembre setembro wrzesnia wrzesien сентября",
22 "october octobre oktober octubre ottobre outubro pazdziernika pazdziernik октября",
23 "november novembre noviembre novembro listopada listopad ноября",
24 "december decembre dezember diciembre dicembre dezembro grudnia grudzien декабря",
25];
26
27static MONTHS: LazyLock<HashMap<String, u32>> = LazyLock::new(|| {
28 let mut m = HashMap::new();
29 for (i, names) in MONTH_NAMES.iter().enumerate() {
30 for name in names.split_whitespace() {
31 m.insert(name.to_string(), i as u32 + 1);
32 }
33 }
34 let mut abbr: HashMap<String, HashSet<u32>> = HashMap::new();
35 for (name, &month) in &m {
36 let prefix: String = name.chars().take(3).collect();
37 abbr.entry(prefix).or_default().insert(month);
38 }
39 for (name, months) in abbr {
40 if months.len() == 1 {
41 m.entry(name).or_insert(*months.iter().next().unwrap());
42 }
43 }
44 for (k, v) in [("janv", 1), ("fevr", 2), ("juil", 7), ("sept", 9)] {
45 m.insert(k.into(), v);
46 }
47 m
48});
49
50static WEEKDAYS: LazyLock<HashSet<&'static str>> = LazyLock::new(|| {
51 "monday tuesday wednesday thursday friday saturday sunday mon tue tues wed thu thur thurs fri sat sun \
52 lundi mardi mercredi jeudi vendredi samedi dimanche montag dienstag mittwoch donnerstag freitag samstag sonntag \
53 lunes martes miercoles jueves viernes sabado domingo lunedi martedi mercoledi giovedi venerdi sabato domenica \
54 segunda terca quarta quinta sexta"
55 .split_whitespace()
56 .collect()
57});
58
59fn re(p: &str) -> Regex {
60 Regex::new(p).expect("d1_parse regex")
61}
62
63static NUMERIC_DATE: LazyLock<Regex> = LazyLock::new(|| re(r"(?<!\d)(\d{1,4})([/.-])(\d{1,2})\2(\d{2,4})(?!\d)"));
64static NUMERIC_DATE_FULL: LazyLock<Regex> = LazyLock::new(|| re(r"^(\d{1,4})([/.-])(\d{1,2})\2(\d{2,4})$"));
65static CJK_DATE_FULL: LazyLock<Regex> =
66 LazyLock::new(|| re(r"^(\d{4})\s*[年년]\s*(\d{1,2})\s*[月월]\s*(\d{1,2})\s*[日일]$"));
67static WORD_DATE: LazyLock<Regex> = LazyLock::new(|| {
68 let mut names: Vec<&String> = MONTHS.keys().collect();
69 names.sort_by_key(|n| std::cmp::Reverse(n.chars().count()));
70 let alt = names.iter().map(|n| fancy_regex::escape(n).into_owned()).collect::<Vec<_>>().join("|");
71 re(&format!(
72 r"\b(?:\d{{1,2}}\w*[ .]+(?:de\s+)?(?:{alt})[ ,.]+(?:de\s+)?\d{{4}}|(?:{alt})[ .]+\d{{1,2}}\w*[ ,.]+\d{{4}})\b"
73 ))
74});
75
76const CURRENCIES: [(Option<&str>, &[&str]); 4] = [
79 (Some("."), &["$", "£", "₹", "USD", "GBP", "CAD", "AUD", "NZD", "INR", "Rs.", "Rs", "MXN", "CNY", "RMB", "元", "HKD", "SGD"]),
80 (Some(","), &["R$", "BRL", "₽", "RUB", "zł", "PLN", "kr", "SEK", "NOK", "DKK", "₺", "TRY"]),
81 (Some(""), &["¥", "¥", "円", "₩", "₫", "Rp.", "Rp", "IDR", "JPY", "KRW", "VND", "CLP"]),
82 (None, &["€", "EUR", "CHF"]),
83];
84
85fn casefold(s: &str) -> String {
86 s.to_lowercase()
87}
88
89static NAMES: LazyLock<HashMap<String, &'static str>> = LazyLock::new(|| {
90 CURRENCIES.iter().flat_map(|(_, cs)| cs.iter().map(|c| (casefold(c), *c))).collect()
91});
92static MARK: LazyLock<HashMap<String, Option<&'static str>>> = LazyLock::new(|| {
93 CURRENCIES.iter().flat_map(|(m, cs)| cs.iter().map(move |c| (casefold(c), *m))).collect()
94});
95static CURRENCY: LazyLock<String> = LazyLock::new(|| {
96 let mut names: Vec<&str> = NAMES.values().copied().collect();
97 names.sort_by_key(|n| std::cmp::Reverse(n.chars().count()));
98 names.dedup();
99 let alts: Vec<String> = names
100 .iter()
101 .map(|c| {
102 let first = c.chars().next().unwrap();
103 if first.is_ascii() && first.is_alphabetic() {
104 format!(r"\b{}(?![a-z])", fancy_regex::escape(c))
105 } else {
106 fancy_regex::escape(c).into_owned()
107 }
108 })
109 .collect();
110 format!("(?i:{})", alts.join("|"))
111});
112static CURRENCY_RE: LazyLock<Regex> = LazyLock::new(|| re(&CURRENCY));
113
114const SCALE_WORDS: &[(&str, u64)] = &[
115 ("thousand", 1_000), ("mil", 1_000), ("tausend", 1_000), ("тыс", 1_000), ("千", 1_000),
116 ("万", 10_000), ("萬", 10_000), ("lakh", 100_000), ("lakhs", 100_000), ("crore", 10_000_000),
117 ("crores", 10_000_000), ("million", 1_000_000), ("millions", 1_000_000), ("mio", 1_000_000),
118 ("mln", 1_000_000), ("millón", 1_000_000), ("millones", 1_000_000), ("milhão", 1_000_000),
119 ("milhões", 1_000_000), ("milione", 1_000_000), ("milioni", 1_000_000), ("млн", 1_000_000),
120 ("亿", 100_000_000), ("億", 100_000_000), ("billion", 1_000_000_000), ("billions", 1_000_000_000),
121 ("milliard", 1_000_000_000), ("milliards", 1_000_000_000), ("milliarde", 1_000_000_000),
122 ("milliarden", 1_000_000_000), ("mrd", 1_000_000_000), ("miliardo", 1_000_000_000),
123 ("miliardi", 1_000_000_000), ("млрд", 1_000_000_000), ("trillion", 1_000_000_000_000),
124];
125
126static SCALE_RE: LazyLock<Regex> = LazyLock::new(|| {
127 let mut words: Vec<&str> = SCALE_WORDS.iter().map(|(w, _)| *w).collect();
128 words.sort_by_key(|w| std::cmp::Reverse(w.chars().count()));
129 words.dedup();
130 let alt = words.iter().map(|w| fancy_regex::escape(w).into_owned()).collect::<Vec<_>>().join("|");
131 re(&format!(r"(?i)\s*({alt})\.?$"))
132});
133static SUFFIX_RE: LazyLock<Regex> = LazyLock::new(|| re(r"(?i)(?<=\d)(k|m|bn|b)$"));
134static COUNTED: LazyLock<Regex> = LazyLock::new(|| {
135 re(r"(?i)\b(?:money|price|invoice|revenue|cost|count|quantity|population|people|items|units|users|downloads)\b")
136});
137static GROUPED: LazyLock<Regex> = LazyLock::new(|| {
138 re(r"(?<![\d.,])\d{1,3}(?:([,. \x{00a0}\x{202f}'’])\d{3})+(?!\1)([.,])\d+(?![\d.,])")
139});
140static SHORT: LazyLock<Regex> = LazyLock::new(|| re(r"(?<![\d.,])\d+([.,])\d{1,2}(?![\d.,])"));
141static REPEATED: LazyLock<Regex> = LazyLock::new(|| re(r"(?<![\d.,])\d{1,3}(?:([.,])\d{3}){2,}(?![\d.,])"));
142static LOCALE: LazyLock<Regex> = LazyLock::new(|| {
143 re(r#"\b(?:locale|language|lang)["']?\s*[=:]\s*["']?(en|fr|de|es|it|pt|nl|pl|ru|sv|ja|zh|ko)(?:[-_][a-z]{2})?\b"#)
144});
145
146fn scale_of(word: &str) -> u64 {
147 let w = word.to_lowercase();
148 SCALE_WORDS.iter().find(|(k, _)| *k == w).map(|(_, v)| *v).unwrap_or(0)
149}
150
151fn one<T: Clone + Eq + std::hash::Hash>(values: &HashSet<T>) -> Option<T> {
152 if values.len() == 1 { values.iter().next().cloned() } else { None }
153}
154
155pub fn written_unit(text: &str) -> Option<String> {
157 if text.trim_end().ends_with('%') {
158 return Some("%".into());
159 }
160 let markers: Vec<String> = CURRENCY_RE
161 .find_iter(text)
162 .filter_map(|m| m.ok())
163 .map(|m| m.as_str().to_string())
164 .collect();
165 if markers.len() == 1 {
166 NAMES.get(&casefold(&markers[0])).map(|s| s.to_string())
167 } else {
168 None
169 }
170}
171
172fn currency_marks(text: &str) -> HashSet<String> {
173 CURRENCY_RE
174 .find_iter(text)
175 .filter_map(|m| m.ok())
176 .filter_map(|m| MARK.get(&casefold(m.as_str())).copied().flatten())
177 .map(str::to_string)
178 .collect()
179}
180
181fn decimal_mark(written: &str, context: &str) -> Option<String> {
182 let context = re(r"<\?[^>]*\?>").replace_all(context, "").into_owned();
183 let mut numbers: HashSet<String> = HashSet::new();
184 for c in GROUPED.captures_iter(&context).flatten() {
185 numbers.insert(c.get(2).unwrap().as_str().to_string());
186 }
187 for c in SHORT.captures_iter(&context).flatten() {
188 numbers.insert(c.get(1).unwrap().as_str().to_string());
189 }
190 for c in REPEATED.captures_iter(&context).flatten() {
191 numbers.insert(if c.get(1).unwrap().as_str() == "." { ",".into() } else { ".".into() });
192 }
193 let folded = casefold(&context);
194 let locales: HashSet<String> = LOCALE
195 .captures_iter(&folded)
196 .flatten()
197 .map(|c| {
198 let lang = c.get(1).unwrap().as_str();
199 if ["en", "ja", "zh", "ko"].contains(&lang) { ".".into() } else { ",".into() }
200 })
201 .collect();
202 let candidates = [currency_marks(written), numbers, currency_marks(&context), locales];
203 let first = candidates.iter().find(|m| !m.is_empty()).cloned().unwrap_or_default();
204 one(&first)
205}
206
207fn digits(text: &str, written: &str, context: &str) -> Option<String> {
208 if !re(r"^\d[\d., '’]*$").is_match(text).unwrap_or(false) {
209 return None;
210 }
211 if re(r"[ '’]").is_match(text).unwrap_or(false) {
212 let g = re(r"^(\d{1,3}(?:[ '’]\d{3})+)([.,]\d+)?$").captures(text).ok().flatten()?;
213 let joined = re(r"[ '’]").replace_all(g.get(1)?.as_str(), "").into_owned()
214 + g.get(2).map_or("", |m| m.as_str());
215 return digits(&joined, written, context);
216 }
217 let both = format!("{written}\n{context}");
218 if re(r"^\d{1,2}(?:,\d{2})+,\d{3}(?:\.\d+)?$").is_match(text).unwrap_or(false)
219 && re(r"(?i)₹|\bINR\b|\bRs\b|lakh|crore").is_match(&both).unwrap_or(false)
220 {
221 return Some(text.replace(',', ""));
222 }
223 if text.contains('.') && text.contains(',') {
224 let (point, group) = if text.rfind('.') > text.rfind(',') { (".", ",") } else { (",", ".") };
225 let pat = format!(r"^\d{{1,3}}(?:{}\d{{3}})+{}\d+$", fancy_regex::escape(group), fancy_regex::escape(point));
226 if !re(&pat).is_match(text).unwrap_or(false) {
227 return None;
228 }
229 return Some(text.replace(group, "").replace(point, "."));
230 }
231 if !text.contains('.') && !text.contains(',') {
232 return Some(text.to_string());
233 }
234 let mark = if text.contains('.') { '.' } else { ',' };
235 let parts: Vec<&str> = text.split(mark).collect();
236 if parts.iter().any(|p| p.is_empty()) {
237 return None;
238 }
239 let point = decimal_mark(written, context);
240 if parts.len() > 2 || point.as_deref().is_some_and(|p| p != mark.to_string()) {
241 let ok = (1..=3).contains(&parts[0].len()) && parts[1..].iter().all(|p| p.len() == 3);
242 return ok.then(|| parts.concat());
243 }
244 if point.is_none() && parts[1].len() == 3 && parts[0].len() <= 3 {
245 return None;
246 }
247 Some(parts.join("."))
248}
249
250pub fn parse_number(text: &str, context: &str, unit: Option<&str>) -> Value {
254 let mut context = context.to_string();
255 if let Some(u) = unit {
256 let full = format!("^(?:{})$", *CURRENCY);
257 if re(&full).is_match(u.trim()).unwrap_or(false) {
258 context.push('\n');
259 context.push_str(u);
260 }
261 }
262 let written = text;
263 let mut t = text.trim().replace('\u{2212}', "-").replace(['\u{00a0}', '\u{202f}'], " ");
264 if let Some(s) = t.strip_suffix('%') {
265 t = s.to_string();
266 }
267 let mut t = t.trim_end().to_string();
268 let negative = t.starts_with('(') && t.ends_with(')');
269 if negative {
270 t = t[1..t.len() - 1].trim().to_string();
271 }
272 let (mut sign, mut currency): (Option<char>, Option<String>) = (None, None);
273 let both = re(&format!(r"^(?:({c})\s*(.*)|(.*?)\s*({c}))$", c = *CURRENCY));
274 for _ in 0..2 {
275 if currency.is_none() {
276 if let Ok(Some(m)) = both.captures(&t) {
277 if let Some(c) = m.get(1) {
278 currency = Some(c.as_str().to_string());
279 t = m.get(2).map_or("", |x| x.as_str()).trim().to_string();
280 } else if let Some(c) = m.get(4) {
281 currency = Some(c.as_str().to_string());
282 t = m.get(3).map_or("", |x| x.as_str()).trim().to_string();
283 }
284 }
285 }
286 if sign.is_none() && (t.starts_with('-') || t.starts_with('+')) {
287 sign = t.chars().next();
288 t = t[1..].trim().to_string();
289 }
290 }
291 if negative && sign.is_some() {
292 return Value::Null;
293 }
294 let mut scale: u64 = 1;
295 if let Ok(Some(w)) = SCALE_RE.captures(&t) {
296 let word = w.get(1).unwrap().as_str();
297 scale = if word == "Billion" { 0 } else { scale_of(word) };
298 t = t[..w.get(0).unwrap().start()].to_string();
299 } else if let Ok(Some(s)) = SUFFIX_RE.captures(&t) {
300 let both = format!("{written}\n{context}");
301 if !(CURRENCY_RE.is_match(&both).unwrap_or(false) || COUNTED.is_match(&context).unwrap_or(false)) {
302 return Value::Null;
303 }
304 scale = match s.get(1).unwrap().as_str().to_lowercase().as_str() {
305 "k" => 1_000,
306 "m" => 1_000_000,
307 _ => 1_000_000_000,
308 };
309 t = t[..s.get(0).unwrap().start()].to_string();
310 }
311 let Some(d) = digits(&t, written, &context) else {
312 return Value::Null;
313 };
314 if scale == 0 {
315 return Value::Null;
316 }
317 let sign = if negative { "-" } else if sign == Some('-') { "-" } else { "" };
318 let (int_part, frac) = d.split_once('.').unwrap_or((&d, ""));
320 let frac_trim = frac.trim_end_matches('0');
321 let shift = scale.to_string().len() - 1; if frac_trim.len() <= shift {
323 let mut s = format!("{int_part}{frac_trim}");
324 s.push_str(&"0".repeat(shift - frac_trim.len()));
325 let s = s.trim_start_matches('0');
326 if let Ok(v) = s.parse::<i64>() {
327 return json!(if sign == "-" { -v } else { v });
328 }
329 }
330 let v: f64 = format!("{sign}{int_part}.{frac}").parse::<f64>().unwrap_or(f64::NAN) * scale as f64;
331 if v.is_finite() { json!(v) } else { Value::Null }
332}
333
334fn fold(text: &str) -> String {
335 casefold(text).nfkd().filter(|c| !unicode_normalization::char::is_combining_mark(*c)).collect()
336}
337
338fn valid_date(y: i32, m: u32, d: u32) -> Option<String> {
339 let days = match m {
340 1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
341 4 | 6 | 9 | 11 => 30,
342 2 if (y % 4 == 0 && y % 100 != 0) || y % 400 == 0 => 29,
343 2 => 28,
344 _ => return None,
345 };
346 (y >= 1 && (1..=days).contains(&d)).then(|| format!("{y:04}-{m:02}-{d:02}"))
347}
348
349fn date_order(context: &str, folded: &str) -> Option<String> {
350 let (day, month, year) = (
351 r"(?:dd?|jj?|tt?|gg?|day|jour|tag|dia|giorno)",
352 r"(?:mm?|month|mois|monat|mes|mese)",
353 r"(?:yy(?:yy)?|aa(?:aa)?|jj(?:jj)?|year|annee|jahr|ano|anno)",
354 );
355 let formats = [
356 (format!(r"\b{day}\s*[/.-]\s*{month}\s*[/.-]\s*{year}\b"), "dmy"),
357 (format!(r"\b{month}\s*[/.-]\s*{day}\s*[/.-]\s*{year}\b"), "mdy"),
358 ];
359 let explicit: HashSet<String> = formats
360 .iter()
361 .filter(|(p, _)| re(p).is_match(folded).unwrap_or(false))
362 .map(|(_, o)| o.to_string())
363 .collect();
364 if !explicit.is_empty() {
365 return one(&explicit);
366 }
367 let loc = re(r#"\b(?:locale|language|lang|langue|sprache|idioma|lingua)["']?\s*[=:]\s*["']?(en(?:[-_](?:us|gb|au|ca))?|fr(?:[-_]fr)?|de(?:[-_]de)?|es(?:[-_]es)?|it(?:[-_]it)?)\b"#);
368 let locales: HashSet<String> = loc
369 .captures_iter(folded)
370 .flatten()
371 .map(|c| {
372 let l = c.get(1).unwrap().as_str().replace('_', "-");
373 if l == "en" || l == "en-us" { "mdy".into() } else { "dmy".into() }
374 })
375 .collect();
376 if !locales.is_empty() {
377 return one(&locales);
378 }
379 let mut orders = HashSet::new();
380 for m in NUMERIC_DATE.captures_iter(context).flatten() {
381 let (a, b): (u32, u32) = (m[1].parse().unwrap_or(0), m[3].parse().unwrap_or(0));
382 let year: i32 = m[4].parse().unwrap_or(0);
383 if m[4].len() != 4 || a > 31 || b > 31 || a < 1 || b < 1 {
384 continue;
385 }
386 for (d, mo, order) in [(a, b, "dmy"), (b, a, "mdy")] {
387 if valid_date(year, mo, d).is_some() && ((a > 12) != (b > 12)) {
388 orders.insert(order.to_string());
389 }
390 }
391 }
392 one(&orders)
393}
394
395pub fn parse_date(text: &str, context: &str) -> (Option<String>, bool) {
397 let folded = fold(context);
398 let order = date_order(context, &folded);
399 parse_date_in(text, context, &folded, order.as_deref())
400}
401
402fn parse_date_in(text: &str, context: &str, folded: &str, order: Option<&str>) -> (Option<String>, bool) {
403 let text = text.trim();
404 if text.is_empty() {
405 return (None, false);
406 }
407 let timed = re(r"^(.+?)[T\s]+(\d{1,2}:\d{2}(?::\d{2}(?:\.\d+)?)?(?:Z|[+-]\d{2}:\d{2})?)$");
408 if let Ok(Some(c)) = timed.captures(text) {
409 let (date, ambiguous) = parse_date_in(&c[1], context, folded, order);
410 let Some(date) = date else {
411 return (None, ambiguous);
412 };
413 let mut clock = c[2].to_string();
415 if clock.as_bytes().get(1) == Some(&b':') {
416 clock = format!("0{clock}");
417 }
418 let split = clock.find(['Z', '+', '-']).unwrap_or(clock.len());
419 let (time, zone) = clock.split_at(split);
420 let zone = if zone == "Z" { "+00:00" } else { zone };
421 let time = if time.len() == 5 { format!("{time}:00") } else { time.to_string() };
422 return (Some(format!("{date}T{time}{zone}")), false);
423 }
424 if let Ok(Some(m)) = NUMERIC_DATE_FULL.captures(text) {
425 let (a, b, c): (i32, i32, i32) = (m[1].parse().unwrap(), m[3].parse().unwrap(), m[4].parse().unwrap());
426 let candidates: Vec<(i32, i32, i32)> = if m[1].len() == 4 {
427 vec![(a, b, c)]
428 } else if m[4].len() == 4 {
429 cands(c, a, b, order)
430 } else {
431 if m[4].len() != 2 {
432 return (None, false);
433 }
434 let mut complete: Vec<String> = NUMERIC_DATE
435 .captures_iter(context)
436 .flatten()
437 .filter(|x| x[1].len() == 4 || x[4].len() == 4)
438 .map(|x| x[0].to_string())
439 .collect();
440 complete.extend(WORD_DATE.find_iter(folded).flatten().map(|x| x.as_str().to_string()));
441 let mut centuries = HashSet::new();
442 for cand in complete {
443 if let (Some(v), false) = parse_date_in(&cand, context, folded, order) {
444 centuries.insert(v[..4].parse::<i32>().unwrap() / 100 * 100);
445 }
446 }
447 if centuries.len() != 1 {
448 return (None, false);
449 }
450 cands(centuries.into_iter().next().unwrap() + c, a, b, order)
451 };
452 let values: HashSet<String> = candidates
453 .iter()
454 .filter_map(|&(y, mo, d)| (mo > 0 && d > 0).then(|| valid_date(y, mo as u32, d as u32)).flatten())
455 .collect();
456 if values.len() > 1 {
457 return (None, true);
458 }
459 return (values.into_iter().next(), false);
460 }
461 if let Ok(Some(c)) = CJK_DATE_FULL.captures(text) {
462 return (valid_date(c[1].parse().unwrap(), c[2].parse().unwrap(), c[3].parse().unwrap()), false);
463 }
464 let mut words = fold(text);
465 words = re(r"(?<=\d)(?:st|nd|rd|th|er)\b").replace_all(&words, "").into_owned();
466 words = re(r"(?<=\d)\.?(?:o|a|°)\b").replace_all(&words, "").into_owned();
467 words = re(r"\b(?:de|of|the|le|el|il|den)\b").replace_all(&words, " ").into_owned();
468 words = re(r"[,.]").replace_all(&words, " ").into_owned();
469 let mut w: Vec<&str> = words.split_whitespace().collect();
470 if let Some(first) = w.first() {
471 if WEEKDAYS.contains(first.trim_end_matches("-feira")) {
472 w.remove(0);
473 }
474 }
475 if w.len() != 3 {
476 return (None, false);
477 }
478 let (month, day, year) = if let Some(&m) = MONTHS.get(w[0]) {
479 (m, w[1], w[2])
480 } else if let Some(&m) = MONTHS.get(w[1]) {
481 (m, w[0], w[2])
482 } else {
483 return (None, false);
484 };
485 if !day.chars().all(|c| c.is_ascii_digit()) || !year.chars().all(|c| c.is_ascii_digit()) || year.len() != 4 {
486 return (None, false);
487 }
488 (valid_date(year.parse().unwrap(), month, day.parse().unwrap_or(0)), false)
489}
490
491fn cands(year: i32, a: i32, b: i32, order: Option<&str>) -> Vec<(i32, i32, i32)> {
492 let (dmy, mdy) = ((year, b, a), (year, a, b));
493 match order {
494 None => vec![dmy, mdy],
495 Some("dmy") => vec![dmy],
496 Some(_) => vec![mdy],
497 }
498}
499
500#[cfg(test)]
501mod tests {
502 use super::*;
503
504 const INVOICE: &str = "ACME SA\nInvoice 2026-117\nTotal due: 1 234,50 EUR\nPay before 03/04/2026 (day/month/year)";
505
506 #[test]
507 fn invoice_values_read_like_the_reference() {
508 assert_eq!(parse_number("1 234,50 EUR", INVOICE, None), json!(1234.5));
509 assert_eq!(written_unit("1 234,50 EUR").as_deref(), Some("EUR"));
510 assert_eq!(parse_date("03/04/2026", INVOICE), (Some("2026-04-03".into()), false));
511 assert_eq!(parse_number("4471", "", None), json!(4471));
512 }
513
514 #[test]
515 fn numbers_and_dates() {
516 assert_eq!(parse_number("$1.5M", "", None), json!(1500000));
517 assert_eq!(parse_number("12%", "", None), json!(12));
518 assert_eq!(parse_number("1,250", "", None), Value::Null);
519 assert_eq!(parse_number("-3.25", "", None), json!(-3.25));
520 assert_eq!(parse_date("20 octobre 2026", "").0.as_deref(), Some("2026-10-20"));
521 assert_eq!(parse_date("2026年10月20日", "").0.as_deref(), Some("2026-10-20"));
522 assert_eq!(parse_date("03/04/2026", ""), (None, true));
523 }
524}