Skip to main content

cortiq_engine/
d1_parse.rs

1//! d1's strict readings of written values (`parsing.py`): a read number's
2//! magnitude (currency and percent set apart, scale words applied, the
3//! decimal mark settled from evidence) and a read date as ISO — no
4//! arithmetic, conversion or invented date components.
5
6use 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
76/// Currencies by the decimal mark their amounts are written with: `""` for
77/// none, `None` for both in use.
78const 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
155/// Only an explicitly written percent or currency marker.
156pub 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
250/// The written magnitude (percent points included): currency and percent
251/// set apart, a scale word applied; null when the writing does not settle
252/// one value.
253pub 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    // Decimal arithmetic on the digit string: an integer stays one.
319    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; // scales are powers of ten
322    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
395/// ISO value and an unresolved-ambiguity flag (`parse_date`).
396pub 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        // datetime.fromisoformat(...).isoformat(): HH:MM:SS, the zone as ±HH:MM.
414        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}