ukrainian-tn 0.6.0

Ukrainian text normalization, tokenization and sentence splitting: reads numbers, dates, units, currencies and abbreviations as words
Documentation
//! Inflection tables and the rules that pick a form.

use std::collections::HashMap;
use std::sync::LazyLock;

use super::lexicon::Forms;

/// Picks the one/few/many form Ukrainian grammar requires for `n`.
pub(crate) fn plural(n: u64, forms: &Forms) -> &'static str {
    plural_of(n, forms.one, forms.few, forms.many)
}

/// [`plural`] over forms that are not `'static`.
pub(crate) fn plural_of<'a>(n: u64, one: &'a str, few: &'a str, many: &'a str) -> &'a str {
    if n % 10 == 1 && n % 100 != 11 {
        one
    } else if (2..=4).contains(&(n % 10)) && !(12..=14).contains(&(n % 100)) {
        few
    } else {
        many
    }
}

/// Rewrites a trailing `один`/`два` into their feminine forms.
pub(crate) fn feminine_last(words: &mut [String]) {
    if let Some(last) = words.last_mut() {
        match last.as_str() {
            "один" => *last = "одна".into(),
            "два" => *last = "дві".into(),
            _ => {}
        }
    }
}

/// Rewrites a trailing `один` into its neuter form.
pub(crate) fn neuter_last(words: &mut [String]) {
    if let Some(last) = words.last_mut() {
        if last == "один" {
            *last = "одне".into();
        }
    }
}

fn map(pairs: &[(&'static str, &'static str)]) -> HashMap<&'static str, &'static str> {
    pairs.iter().copied().collect()
}

/// Cardinal number words mapped to their ordinal stems.
#[rustfmt::skip]
pub(crate) static CARDINAL_TO_ORDINAL: LazyLock<HashMap<&'static str, &'static str>> = LazyLock::new(|| {
    map(&[
        ("нуль", "нульовий"), ("один", "перший"), ("одна", "перший"), ("два", "другий"),
        ("дві", "другий"), ("три", "третій"), ("чотири", "четвертий"), ("п'ять", "п'ятий"),
        ("шість", "шостий"), ("сім", "сьомий"), ("вісім", "восьмий"), ("дев'ять", "дев'ятий"),
        ("десять", "десятий"), ("одинадцять", "одинадцятий"), ("дванадцять", "дванадцятий"),
        ("тринадцять", "тринадцятий"), ("чотирнадцять", "чотирнадцятий"),
        ("п'ятнадцять", "п'ятнадцятий"), ("шістнадцять", "шістнадцятий"),
        ("сімнадцять", "сімнадцятий"), ("вісімнадцять", "вісімнадцятий"),
        ("дев'ятнадцять", "дев'ятнадцятий"), ("двадцять", "двадцятий"), ("тридцять", "тридцятий"),
        ("сорок", "сороковий"), ("п'ятдесят", "п'ятдесятий"), ("шістдесят", "шістдесятий"),
        ("сімдесят", "сімдесятий"), ("вісімдесят", "вісімдесятий"), ("дев'яносто", "дев'яностий"),
        ("сто", "сотий"), ("двісті", "двохсотий"), ("триста", "трьохсотий"),
        ("чотириста", "чотирьохсотий"), ("п'ятсот", "п'ятисотий"), ("шістсот", "шестисотий"),
        ("сімсот", "семисотий"), ("вісімсот", "восьмисотий"), ("дев'ятсот", "дев'ятисотий"),
        ("тисяча", "тисячний"), ("тисячі", "тисячний"), ("тисяч", "тисячний"),
    ])
});

/// Genitive, dative, instrumental and locative forms of the cardinal words.
#[rustfmt::skip]
pub(crate) static CASE_FORMS: LazyLock<HashMap<&'static str, [&'static str; 4]>> = LazyLock::new(|| {
    [
        ("нуль", ["нуля", "нулю", "нулем", "нулі"]),
        ("один", ["одного", "одному", "одним", "одному"]),
        ("одна", ["однієї", "одній", "однією", "одній"]),
        ("два", ["двох", "двом", "двома", "двох"]),
        ("дві", ["двох", "двом", "двома", "двох"]),
        ("три", ["трьох", "трьом", "трьома", "трьох"]),
        ("чотири", ["чотирьох", "чотирьом", "чотирма", "чотирьох"]),
        ("п'ять", ["п'яти", "п'яти", "п'ятьма", "п'яти"]),
        ("шість", ["шести", "шести", "шістьма", "шести"]),
        ("сім", ["семи", "семи", "сьома", "семи"]),
        ("вісім", ["восьми", "восьми", "вісьмома", "восьми"]),
        ("дев'ять", ["дев'яти", "дев'яти", "дев'ятьма", "дев'яти"]),
        ("десять", ["десяти", "десяти", "десятьма", "десяти"]),
        ("одинадцять", ["одинадцяти", "одинадцяти", "одинадцятьма", "одинадцяти"]),
        ("дванадцять", ["дванадцяти", "дванадцяти", "дванадцятьма", "дванадцяти"]),
        ("тринадцять", ["тринадцяти", "тринадцяти", "тринадцятьма", "тринадцяти"]),
        ("чотирнадцять", ["чотирнадцяти", "чотирнадцяти", "чотирнадцятьма", "чотирнадцяти"]),
        ("п'ятнадцять", ["п'ятнадцяти", "п'ятнадцяти", "п'ятнадцятьма", "п'ятнадцяти"]),
        ("шістнадцять", ["шістнадцяти", "шістнадцяти", "шістнадцятьма", "шістнадцяти"]),
        ("сімнадцять", ["сімнадцяти", "сімнадцяти", "сімнадцятьма", "сімнадцяти"]),
        ("вісімнадцять", ["вісімнадцяти", "вісімнадцяти", "вісімнадцятьма", "вісімнадцяти"]),
        ("дев'ятнадцять", ["дев'ятнадцяти", "дев'ятнадцяти", "дев'ятнадцятьма", "дев'ятнадцяти"]),
        ("двадцять", ["двадцяти", "двадцяти", "двадцятьма", "двадцяти"]),
        ("тридцять", ["тридцяти", "тридцяти", "тридцятьма", "тридцяти"]),
        ("сорок", ["сорока", "сорока", "сорока", "сорока"]),
        ("п'ятдесят", ["п'ятдесяти", "п'ятдесяти", "п'ятдесятьма", "п'ятдесяти"]),
        ("шістдесят", ["шістдесяти", "шістдесяти", "шістдесятьма", "шістдесяти"]),
        ("сімдесят", ["сімдесяти", "сімдесяти", "сімдесятьма", "сімдесяти"]),
        ("вісімдесят", ["вісімдесяти", "вісімдесяти", "вісімдесятьма", "вісімдесяти"]),
        ("дев'яносто", ["дев'яноста", "дев'яноста", "дев'яноста", "дев'яноста"]),
        ("сто", ["ста", "ста", "ста", "ста"]),
        ("двісті", ["двохсот", "двомстам", "двомастами", "двохстах"]),
        ("триста", ["трьохсот", "трьомстам", "трьомастами", "трьохстах"]),
        ("чотириста", ["чотирьохсот", "чотирьомстам", "чотирмастами", "чотирьохстах"]),
        ("п'ятсот", ["п'ятисот", "п'ятистам", "п'ятьмастами", "п'ятистах"]),
        ("шістсот", ["шестисот", "шестистам", "шістьмастами", "шестистах"]),
        ("сімсот", ["семисот", "семистам", "сьомастами", "семистах"]),
        ("вісімсот", ["восьмисот", "восьмистам", "вісьмомастами", "восьмистах"]),
        ("дев'ятсот", ["дев'ятисот", "дев'ятистам", "дев'ятьмастами", "дев'ятистах"]),
        ("тисяча", ["тисячі", "тисячі", "тисячею", "тисячі"]),
        ("тисячі", ["тисяч", "тисячам", "тисячами", "тисячах"]),
        ("тисяч", ["тисяч", "тисячам", "тисячами", "тисячах"]),
        ("мільйон", ["мільйона", "мільйону", "мільйоном", "мільйоні"]),
        ("мільйони", ["мільйонів", "мільйонам", "мільйонами", "мільйонах"]),
        ("мільйонів", ["мільйонів", "мільйонам", "мільйонами", "мільйонах"]),
        ("мільярд", ["мільярда", "мільярду", "мільярдом", "мільярді"]),
        ("мільярди", ["мільярдів", "мільярдам", "мільярдами", "мільярдах"]),
        ("мільярдів", ["мільярдів", "мільярдам", "мільярдами", "мільярдах"]),
    ]
    .into_iter()
    .collect()
});

/// Combining forms used when a number joins a compound word (`двоповерховий`).
#[rustfmt::skip]
pub(crate) static COMPOUND_PREFIX_FORMS: LazyLock<HashMap<&'static str, &'static str>> = LazyLock::new(|| {
    map(&[
        ("один", "одно"), ("одна", "одно"), ("два", "дво"), ("дві", "дво"), ("три", "три"),
        ("чотири", "чотири"), ("п'ять", "п'яти"), ("шість", "шести"), ("сім", "семи"),
        ("вісім", "восьми"), ("дев'ять", "дев'яти"), ("десять", "десяти"),
        ("одинадцять", "одинадцяти"), ("дванадцять", "дванадцяти"), ("тринадцять", "тринадцяти"),
        ("чотирнадцять", "чотирнадцяти"), ("п'ятнадцять", "п'ятнадцяти"),
        ("шістнадцять", "шістнадцяти"), ("сімнадцять", "сімнадцяти"),
        ("вісімнадцять", "вісімнадцяти"), ("дев'ятнадцять", "дев'ятнадцяти"),
        ("двадцять", "двадцяти"), ("тридцять", "тридцяти"), ("сорок", "сорока"),
        ("п'ятдесят", "п'ятдесяти"), ("шістдесят", "шістдесяти"), ("сімдесят", "сімдесяти"),
        ("вісімдесят", "вісімдесяти"), ("дев'яносто", "дев'яносто"), ("сто", "сто"),
        ("двісті", "двохсот"), ("триста", "трьохсот"), ("чотириста", "чотирьохсот"),
        ("п'ятсот", "п'ятисот"), ("шістсот", "шестисот"), ("сімсот", "семисот"),
        ("вісімсот", "восьмисот"), ("дев'ятсот", "дев'ятисот"), ("тисяча", "тисячо"),
        ("тисячі", "тисячо"), ("тисяч", "тисячо"),
    ])
});

/// Returns the spoken name of an uppercase Ukrainian letter.
pub(crate) const fn pronunciation(letter: char) -> Option<&'static str> {
    Some(match letter {
        'А' => "а",
        'Б' => "бе",
        'В' => "ве",
        'Г' => "ге",
        'Ґ' => "ґе",
        'Д' => "де",
        'Е' => "е",
        'Є' => "є",
        'Ж' => "же",
        'З' => "зе",
        'И' => "и",
        'І' => "і",
        'Ї' => "ї",
        'Й' => "йот",
        'К' => "ка",
        'Л' => "ел",
        'М' => "ем",
        'Н' => "ен",
        'О' => "о",
        'П' => "пе",
        'Р' => "ер",
        'С' => "ес",
        'Т' => "те",
        'У' => "у",
        'Ф' => "еф",
        'Х' => "ха",
        'Ц' => "це",
        'Ч' => "че",
        'Ш' => "ша",
        'Щ' => "ща",
        'Ь' => "м'який знак",
        'Ю' => "ю",
        'Я' => "я",
        _ => return None,
    })
}

/// Latin letter sequences and their Cyrillic transliteration, longest first.
#[rustfmt::skip]
pub(crate) static TRANSLITERATION: LazyLock<HashMap<&'static str, &'static str>> = LazyLock::new(|| {
    map(&[
        ("a", "а"), ("b", "б"), ("c", "к"), ("d", "д"), ("e", "е"), ("f", "ф"), ("g", "г"),
        ("h", "г"), ("i", "і"), ("j", "дж"), ("k", "к"), ("l", "л"), ("m", "м"), ("n", "н"),
        ("o", "о"), ("p", "п"), ("q", "к"), ("r", "р"), ("s", "с"), ("t", "т"), ("u", "у"),
        ("v", "в"), ("w", "в"), ("x", "кс"), ("y", "и"), ("z", "з"), ("sh", "ш"), ("ch", "ч"),
        ("zh", "ж"), ("kh", "х"), ("ts", "ц"), ("yu", "ю"), ("ya", "я"), ("ye", "є"), ("yi", "ї"),
        ("oo", "у"), ("ee", "і"), ("sch", "щ"),
    ])
});

/// Hard-stem adjective endings, keyed by the form names the API accepts.
#[rustfmt::skip]
static ENDINGS: LazyLock<HashMap<&'static str, &'static str>> = LazyLock::new(|| {
    map(&[
        ("nom_n", "е"), ("nom_f", "а"), ("nom_pl", "і"), ("gen", "ого"), ("dat", "ому"),
        ("prep", "ому"), ("loc", "ому"), ("pl", "их"), ("loc_pl", "их"), ("acc_f", "у"),
        ("gen_f", "ої"), ("ins", "им"), ("ins_f", "ою"), ("ins_pl", "ими"), ("loc_f", "ій"),
    ])
});

/// Soft-stem adjective endings, used by ordinals such as `третій`.
#[rustfmt::skip]
static SOFT_ENDINGS: LazyLock<HashMap<&'static str, &'static str>> = LazyLock::new(|| {
    map(&[
        ("nom_n", "є"), ("nom_f", "я"), ("gen", "ього"), ("dat", "ьому"), ("prep", "ьому"),
        ("loc", "ьому"), ("pl", "іх"), ("loc_pl", "іх"), ("acc_f", "ю"), ("gen_f", "ьої"),
        ("ins", "ім"), ("ins_f", "ьою"), ("ins_pl", "іми"),
    ])
});

/// Inflects an ordinal stem such as `перший` into the requested form.
///
/// Unknown forms leave the stem untouched, matching the reference behaviour.
pub(crate) fn inflect_ordinal(stem: &str, form: &str) -> String {
    if form == "nom_m" {
        return stem.to_owned();
    }
    let Some(ending) = ENDINGS.get(form) else {
        return stem.to_owned();
    };
    let (base, soft) = if let Some(base) = stem.strip_suffix("ій") {
        (base, true)
    } else if let Some(base) = stem.strip_suffix("ий") {
        (base, false)
    } else {
        (stem, false)
    };
    let ending = if soft { SOFT_ENDINGS.get(form).unwrap_or(ending) } else { ending };
    format!("{base}{ending}")
}