duckling 0.4.0

A Rust port of Facebook's Duckling library for parsing natural language into structured data
Documentation
use crate::pattern::{dim, predicate, regex};
use crate::types::{DimensionKind, Rule, TokenData};

use super::helpers::*;
use super::NumeralData;

fn has_grain(td: &TokenData) -> bool {
    matches!(td, TokenData::Numeral(d) if d.grain.is_some_and(|g| g > 1))
}

fn number_between(low: f64, up: f64) -> impl Fn(&TokenData) -> bool {
    move |td| {
        matches!(
            td,
            TokenData::Numeral(d) if !d.multipliable && d.value >= low && d.value < up
        )
    }
}

fn one_of(values: &'static [f64]) -> impl Fn(&TokenData) -> bool {
    move |td| matches!(td, TokenData::Numeral(d) if values.contains(&d.value))
}

fn is_multiple(v: f64, divisor: f64) -> bool {
    (v % divisor).abs() < f64::EPSILON
}

pub fn rules() -> Vec<Rule> {
    vec![
        Rule {
            name: "number (0..15)".to_string(),
            pattern: vec![regex(
                r"((c|z)ero|un(o|a)?|dos|tr(é|e)s|cuatro|cinco|s(e|é)is|siete|ocho|nueve|die(z|s)|once|doce|trece|catorce|quince)",
            )],
            production: Box::new(|nodes| {
                let text = match &nodes[0].token_data {
                    TokenData::RegexMatch(m) => m.group(1)?,
                    _ => return None,
                };
                let value = match text.to_lowercase().as_str() {
                    "zero" | "cero" => 0.0,
                    "un" | "una" | "uno" => 1.0,
                    "dos" => 2.0,
                    "trés" | "tres" => 3.0,
                    "cuatro" => 4.0,
                    "cinco" => 5.0,
                    "seis" | "séis" => 6.0,
                    "siete" => 7.0,
                    "ocho" => 8.0,
                    "nueve" => 9.0,
                    "diez" | "dies" => 10.0,
                    "once" => 11.0,
                    "doce" => 12.0,
                    "trece" => 13.0,
                    "catorce" => 14.0,
                    "quince" => 15.0,
                    _ => return None,
                };
                Some(TokenData::Numeral(NumeralData::new(value)))
            }),
        },
        Rule {
            name: "integer (0-9) with two digits".to_string(),
            pattern: vec![regex(r"((c|z)ero)|0"), predicate(number_between(1.0, 10.0))],
            production: Box::new(|nodes| {
                let v = numeral_data(&nodes[1].token_data)?.value;
                Some(TokenData::Numeral(NumeralData::new(v)))
            }),
        },
        Rule {
            name: "number (16..19 21..29)".to_string(),
            pattern: vec![regex(
                r"(die(c|s)is(é|e)is|diecisiete|dieciocho|diecinueve|veintiun(o|a)|veintid(o|ó)s|veintitr(é|e)s|veinticuatro|veinticinco|veintis(é|e)is|veintisiete|veintiocho|veintinueve)",
            )],
            production: Box::new(|nodes| {
                let text = match &nodes[0].token_data {
                    TokenData::RegexMatch(m) => m.group(1)?,
                    _ => return None,
                };
                let value = match text.to_lowercase().as_str() {
                    "dieciseis" | "diesiséis" | "diesiseis" | "dieciséis" => 16.0,
                    "diecisiete" => 17.0,
                    "dieciocho" => 18.0,
                    "diecinueve" => 19.0,
                    "veintiuno" | "veintiuna" => 21.0,
                    "veintidos" | "veintidós" => 22.0,
                    "veintitrés" | "veintitres" => 23.0,
                    "veinticuatro" => 24.0,
                    "veinticinco" => 25.0,
                    "veintiséis" | "veintiseis" => 26.0,
                    "veintisiete" => 27.0,
                    "veintiocho" => 28.0,
                    "veintinueve" => 29.0,
                    _ => return None,
                };
                Some(TokenData::Numeral(NumeralData::new(value)))
            }),
        },
        Rule {
            name: "number (16..19, two words)".to_string(),
            pattern: vec![
                predicate(|td| matches!(td, TokenData::Numeral(d) if d.value == 10.0)),
                regex("y"),
                predicate(number_between(6.0, 10.0)),
            ],
            production: Box::new(|nodes| {
                let v = numeral_data(&nodes[2].token_data)?.value;
                Some(TokenData::Numeral(NumeralData::new(10.0 + v)))
            }),
        },
        Rule {
            name: "number (20..90)".to_string(),
            pattern: vec![regex(
                r"(veinte|treinta|cuarenta|cincuenta|sesenta|setenta|ochenta|noventa)",
            )],
            production: Box::new(|nodes| {
                let text = match &nodes[0].token_data {
                    TokenData::RegexMatch(m) => m.group(1)?,
                    _ => return None,
                };
                let value = match text.to_lowercase().as_str() {
                    "veinte" => 20.0,
                    "treinta" => 30.0,
                    "cuarenta" => 40.0,
                    "cincuenta" => 50.0,
                    "sesenta" => 60.0,
                    "setenta" => 70.0,
                    "ochenta" => 80.0,
                    "noventa" => 90.0,
                    _ => return None,
                };
                Some(TokenData::Numeral(NumeralData::new(value)))
            }),
        },
        Rule {
            name: "number (21..29 31..39 41..49 51..59 61..69 71..79 81..89 91..99)".to_string(),
            pattern: vec![
                predicate(one_of(&[20.0, 30.0, 40.0, 50.0, 60.0, 70.0, 80.0, 90.0])),
                regex("y"),
                predicate(number_between(1.0, 10.0)),
            ],
            production: Box::new(|nodes| {
                let v1 = numeral_data(&nodes[0].token_data)?.value;
                let v2 = numeral_data(&nodes[2].token_data)?.value;
                Some(TokenData::Numeral(NumeralData::new(v1 + v2)))
            }),
        },
        Rule {
            name: "big number 100 to 1K".to_string(),
            pattern: vec![regex(
                r"(cien(to|tos)?|doscientos|trescientos|cuatrocientos|quinientos|seiscientos|setecientos|ochocientos|novecientos|(un )?mill(o|ó)n)",
            )],
            production: Box::new(|nodes| {
                let text = match &nodes[0].token_data {
                    TokenData::RegexMatch(m) => m.group(1)?,
                    _ => return None,
                };
                let value = match text.to_lowercase().as_str() {
                    "cien" | "cientos" | "ciento" => 100.0,
                    "doscientos" => 200.0,
                    "trescientos" => 300.0,
                    "cuatrocientos" => 400.0,
                    "quinientos" => 500.0,
                    "seiscientos" => 600.0,
                    "setecientos" => 700.0,
                    "ochocientos" => 800.0,
                    "novecientos" => 900.0,
                    "millon" | "millón" | "un millon" | "un millón" => 1_000_000.0,
                    _ => return None,
                };
                Some(TokenData::Numeral(NumeralData::new(value)))
            }),
        },
        Rule {
            name: "1K or 1M in multipliable form".to_string(),
            pattern: vec![regex(r"(mil(lones)?)")],
            production: Box::new(|nodes| {
                let text = match &nodes[0].token_data {
                    TokenData::RegexMatch(m) => m.group(1)?,
                    _ => return None,
                };
                let value = match text.to_lowercase().as_str() {
                    "mil" => 1_000.0,
                    "millones" => 1_000_000.0,
                    _ => return None,
                };
                Some(TokenData::Numeral(
                    NumeralData::new(value).with_multipliable(true),
                ))
            }),
        },
        Rule {
            name: "2..999 <multipliable>".to_string(),
            pattern: vec![
                predicate(number_between(2.0, 1000.0)),
                predicate(is_multipliable),
            ],
            production: Box::new(|nodes| {
                let v1 = numeral_data(&nodes[0].token_data)?.value;
                let v2 = numeral_data(&nodes[1].token_data)?.value;
                Some(TokenData::Numeral(NumeralData::new(v1 * v2)))
            }),
        },
        Rule {
            name: "<thousands> 0..999".to_string(),
            pattern: vec![
                predicate(
                    |td| matches!(td, TokenData::Numeral(d) if d.value > 0.0 && d.value < 1_000_000.0 && is_multiple(d.value, 1000.0)),
                ),
                predicate(number_between(0.0, 999.0)),
            ],
            production: Box::new(|nodes| {
                let v1 = numeral_data(&nodes[0].token_data)?.value;
                let v2 = numeral_data(&nodes[1].token_data)?.value;
                Some(TokenData::Numeral(NumeralData::new(v1 + v2)))
            }),
        },
        Rule {
            name: "<millions> 0..999999".to_string(),
            pattern: vec![
                predicate(
                    |td| matches!(td, TokenData::Numeral(d) if d.value > 0.0 && is_multiple(d.value, 1_000_000.0)),
                ),
                predicate(number_between(0.0, 999_999.0)),
            ],
            production: Box::new(|nodes| {
                let v1 = numeral_data(&nodes[0].token_data)?.value;
                let v2 = numeral_data(&nodes[1].token_data)?.value;
                Some(TokenData::Numeral(NumeralData::new(v1 + v2)))
            }),
        },
        Rule {
            name: "2..9 cientos".to_string(),
            pattern: vec![predicate(number_between(2.0, 10.0)), regex("cientos")],
            production: Box::new(|nodes| {
                let v = numeral_data(&nodes[0].token_data)?.value;
                Some(TokenData::Numeral(NumeralData::new(100.0 * v)))
            }),
        },
        Rule {
            name: "<hundreds> 0..99".to_string(),
            pattern: vec![
                predicate(
                    |td| matches!(td, TokenData::Numeral(d) if d.value > 0.0 && d.value < 1000.0 && is_multiple(d.value, 100.0)),
                ),
                predicate(number_between(0.0, 100.0)),
            ],
            production: Box::new(|nodes| {
                let v1 = numeral_data(&nodes[0].token_data)?.value;
                let v2 = numeral_data(&nodes[1].token_data)?.value;
                Some(TokenData::Numeral(NumeralData::new(v1 + v2)))
            }),
        },
        Rule {
            name: "number dot number".to_string(),
            pattern: vec![
                dim(DimensionKind::Numeral),
                regex("(co(n|ma)|punto)"),
                predicate(|td| !has_grain(td)),
            ],
            production: Box::new(|nodes| {
                let v1 = numeral_data(&nodes[0].token_data)?.value;
                let v2 = numeral_data(&nodes[2].token_data)?.value;
                Some(TokenData::Numeral(NumeralData::new(
                    v1 + decimals_to_double(v2),
                )))
            }),
        },
        Rule {
            name: "dot number".to_string(),
            pattern: vec![regex("coma|punto"), predicate(|td| !has_grain(td))],
            production: Box::new(|nodes| {
                let v = numeral_data(&nodes[1].token_data)?.value;
                Some(TokenData::Numeral(NumeralData::new(decimals_to_double(v))))
            }),
        },
        Rule {
            name: "numbers suffixes (K, M, G)".to_string(),
            pattern: vec![dim(DimensionKind::Numeral), regex("([kmg])")],
            production: Box::new(|nodes| {
                let v = numeral_data(&nodes[0].token_data)?.value;
                let suffix = match &nodes[1].token_data {
                    TokenData::RegexMatch(m) => m.group(1)?.to_lowercase(),
                    _ => return None,
                };
                let mult = match suffix.as_str() {
                    "k" => 1_000.0,
                    "m" => 1_000_000.0,
                    "g" => 1_000_000_000.0,
                    _ => return None,
                };
                Some(TokenData::Numeral(NumeralData::new(v * mult)))
            }),
        },
        Rule {
            name: "numbers prefix with -, negative or minus".to_string(),
            pattern: vec![regex("-|menos|negativ(o|a)"), predicate(is_positive)],
            production: Box::new(|nodes| {
                let v = numeral_data(&nodes[1].token_data)?.value;
                Some(TokenData::Numeral(NumeralData::new(-v)))
            }),
        },
        Rule {
            name: "decimal number with comma".to_string(),
            pattern: vec![regex("(\\d*,\\d+)")],
            production: Box::new(|nodes| {
                let text = match &nodes[0].token_data {
                    TokenData::RegexMatch(m) => m.group(1)?,
                    _ => return None,
                };
                let normalized = if text.starts_with(',') {
                    format!("0{}", text)
                } else {
                    text.to_string()
                };
                let value: f64 = normalized.replace(',', ".").parse().ok()?;
                Some(TokenData::Numeral(NumeralData::new(value)))
            }),
        },
    ]
}