use fancy_regex::Regex;
use std::collections::HashMap;
use std::fmt::Write as _;
use std::sync::LazyLock;
use crate::uktextnorm::lexicon::Gender;
use crate::uktextnorm::morphology::{plural, CASE_FORMS, COMPOUND_PREFIX_FORMS};
use crate::uktextnorm::numbers::{
number_to_words, number_to_words_case_str, number_words_for_gender, ordinal_words,
prefers_many_after_genitive_number,
};
use crate::uktextnorm::patterns::{
CASE_PREP_RE, COUNTED_GENITIVE_RE, COUNTED_NOUNS_RE, COUNTED_PONAD_RE,
};
use crate::uktextnorm::re::{cap, compile, compile_i, matched, sub, sub_ctx, whole};
use crate::uktextnorm::readers::{COUNTED_NOUNS, COUNTED_OBLIQUE, MEASUREMENTS};
use crate::uktextnorm::text::{lower_text, parse_u64, regex_alternation, split_words};
use super::ranges::preceding_word;
fn gender_char(gender: Gender) -> char {
match gender {
Gender::Feminine => 'f',
Gender::Neuter => 'n',
Gender::Masculine => 'm',
}
}
#[rustfmt::skip]
static PREPOSITION_CASE: LazyLock<HashMap<&'static str, &'static str>> = LazyLock::new(|| {
[
("близько", "gen"), ("менше", "gen"), ("більше", "gen"), ("серед", "gen"),
("від", "gen"), ("до", "gen"), ("із", "gen"), ("з", "instr"), ("без", "gen"),
("після", "gen"), ("протягом", "gen"), ("впродовж", "gen"), ("упродовж", "gen"),
("перед", "instr"), ("між", "instr"), ("над", "instr"), ("під", "instr"),
("при", "prep"), ("к", "dat"), ("о", "prep"), ("об", "prep"),
]
.into_iter()
.collect()
});
pub(crate) fn normalize_case_context(text: &str) -> String {
static PONAD_QUANTITY: LazyLock<Regex> =
LazyLock::new(|| compile(r"(^|[^А-Яа-яЄєІіЇїҐґ])(Понад|понад)\s+(\d+)(?!\d)"));
static QUANTIFIED_GENITIVE: LazyLock<Regex> =
LazyLock::new(|| compile(r"(^|[\s(\[{:;,.!?])((?:З|з))\s+(\d+)\s+([^\s,.;:!?]+)"));
static COMPARATIVE_GENITIVE: LazyLock<Regex> = LazyLock::new(|| {
compile(
r"(^|[^А-Яа-яЄєІіЇїҐґ])(Після|після|До|до|Від|від|Без|без)\s+(більш|менш)\s+ніж\s+(\d+)(?!\d)",
)
});
static INSTRUMENTAL: LazyLock<Regex> = LazyLock::new(|| {
compile(
r"(^|[^А-Яа-яЄєІіЇїҐґ])([Зз])\s+(\d+)\s+([а-яєіїґ']{3,}(?:ами|ями|ма))(?![А-Яа-яЄєІіЇїҐґ])",
)
});
static BEFORE_LOCATIVE_ADJECTIVE: LazyLock<Regex> =
LazyLock::new(|| compile(r"(^|[\s(\[{:;,.!?])(У|у|В|в|На|на)\s+(\d+)\s+([^\s,.;:!?]+)"));
static OBLIQUE: LazyLock<Regex> = LazyLock::new(|| {
compile_i(&format!(
r"(^|[^А-Яа-яЄєІіЇїҐґ])(У|у|В|в|На|на)\s+(\d+)\s+({})(?![А-Яа-яЄєІіЇїҐґ])",
regex_alternation(COUNTED_OBLIQUE.keys().copied())
))
});
let text = sub(text, &PONAD_QUANTITY, |m| {
let mut words = number_to_words(parse_u64(cap(m, 3)));
if let Some(rest) = words.strip_prefix("тисяча ") {
words = format!("тисячу {rest}");
}
format!("{}{} {words}", cap(m, 1), cap(m, 2))
});
let text = sub(&text, &QUANTIFIED_GENITIVE, |m| {
let noun = lower_text(cap(m, 4));
if !noun.ends_with("ів") && !noun.ends_with("їв") {
return whole(m).to_owned();
}
format!(
"{}{} {} {}",
cap(m, 1),
cap(m, 2),
number_to_words_case_str(parse_u64(cap(m, 3)), "gen"),
cap(m, 4)
)
});
let text = sub(&text, &COMPARATIVE_GENITIVE, |m| {
format!(
"{}{} {} ніж {}",
cap(m, 1),
cap(m, 2),
cap(m, 3),
number_to_words_case_str(parse_u64(cap(m, 4)), "gen")
)
});
let text = sub(&text, &INSTRUMENTAL, |m| {
format!(
"{}{} {} {}",
cap(m, 1),
cap(m, 2),
number_to_words_case_str(parse_u64(cap(m, 3)), "instr"),
cap(m, 4)
)
});
let text = sub(&text, &BEFORE_LOCATIVE_ADJECTIVE, |m| {
let count = parse_u64(cap(m, 3));
let adjective = lower_text(cap(m, 4));
let locative = ["ому", "ьому", "ій", "их"].iter().any(|s| adjective.ends_with(s));
if count == 1 || !locative {
return whole(m).to_owned();
}
format!(
"{}{} {} {}",
cap(m, 1),
cap(m, 2),
number_to_words_case_str(count, "prep"),
cap(m, 4)
)
});
let text = sub(&text, &CASE_PREP_RE, |m| {
let preposition = lower_text(cap(m, 2));
let Some(&case) = PREPOSITION_CASE.get(preposition.as_str()) else {
return whole(m).to_owned();
};
if matched(m, 4) && case != "gen" {
return whole(m).to_owned();
}
let mut out = format!(
"{}{} {}",
cap(m, 1),
cap(m, 2),
number_to_words_case_str(parse_u64(cap(m, 3)), case)
);
if matched(m, 4) {
let Some(unit) = MEASUREMENTS.get(cap(m, 4)) else {
return whole(m).to_owned();
};
let _ = write!(out, " {}{}", unit.forms.many, cap(m, 5));
}
out
});
sub(&text, &OBLIQUE, |m| {
let noun = cap(m, 4);
match COUNTED_OBLIQUE.get(lower_text(noun).as_str()) {
Some(case) => format!(
"{}{} {} {noun}",
cap(m, 1),
cap(m, 2),
number_to_words_case_str(parse_u64(cap(m, 3)), case)
),
None => whole(m).to_owned(),
}
})
}
pub(crate) fn normalize_counted_noun_context(text: &str) -> String {
let text = sub(text, &COUNTED_PONAD_RE, |m| {
let n = parse_u64(cap(m, 3));
let Some(noun) = COUNTED_NOUNS.get(lower_text(cap(m, 4)).as_str()) else {
return whole(m).to_owned();
};
format!(
"{}{} {} {}",
cap(m, 1),
cap(m, 2),
number_words_for_gender(n, gender_char(noun.gender)),
plural(n, &noun.forms)
)
});
sub(&text, &COUNTED_GENITIVE_RE, |m| {
let n = parse_u64(cap(m, 3));
let Some(noun) = COUNTED_NOUNS.get(lower_text(cap(m, 4)).as_str()) else {
return whole(m).to_owned();
};
if !prefers_many_after_genitive_number(n) {
return whole(m).to_owned();
}
format!(
"{}{} {} {}",
cap(m, 1),
cap(m, 2),
number_to_words_case_str(n, "gen"),
noun.forms.many
)
})
}
pub(crate) fn normalize_counted_nouns(text: &str) -> String {
sub_ctx(text, &COUNTED_NOUNS_RE, |m, prefix| {
let n = parse_u64(cap(m, 2));
let Some(noun) = COUNTED_NOUNS.get(lower_text(cap(m, 3)).as_str()) else {
return whole(m).to_owned();
};
if noun.forms.one == "стаття" {
let previous = preceding_word(prefix);
if matches!(previous.as_str(), "частина" | "пункт" | "розділ" | "параграф" | "глава")
{
return whole(m).to_owned();
}
}
format!(
"{}{} {}",
cap(m, 1),
number_words_for_gender(n, gender_char(noun.gender)),
plural(n, &noun.forms)
)
})
}
pub(crate) fn normalize_ordinal_triggers(text: &str) -> String {
static GENITIVE_CLASS: LazyLock<Regex> = LazyLock::new(|| {
compile_i(r"(^|[^\dА-Яа-яЄєІіЇїҐґ])(\d{1,2})\s+(класу)(?![А-Яа-яЄєІіЇїҐґ])")
});
#[rustfmt::skip]
static TRIGGERS: LazyLock<HashMap<&'static str, &'static str>> = LazyLock::new(|| {
[
("місце", "nom_n"), ("село", "nom_n"), ("місто", "nom_n"), ("століття", "nom_n"),
("ст", "nom_n"), ("клас", "nom_m"), ("курс", "nom_m"), ("раунд", "nom_m"),
("сезон", "nom_m"), ("етап", "nom_m"), ("тур", "nom_m"), ("том", "nom_m"),
("під'їзд", "nom_m"), ("поверх", "nom_m"), ("група", "nom_f"),
("квартира", "nom_f"), ("сторінка", "nom_f"),
]
.into_iter()
.collect()
});
static RE: LazyLock<Regex> = LazyLock::new(|| {
compile_i(concat!(
r"(^|[^\d])(\d{1,4})\s+(місце|село|місто|століття|ст|клас|курс|раунд|сезон|етап",
r"|тур|том|під'їзд|поверх|група|квартира|сторінка)(?![А-Яа-яЄєІіЇїҐґ])"
))
});
let text = sub(text, &GENITIVE_CLASS, |m| {
let number = parse_u64(cap(m, 2));
if number == 0 {
return whole(m).to_owned();
}
format!("{}{} {}", cap(m, 1), ordinal_words(number, "gen"), cap(m, 3))
});
sub(&text, &RE, |m| {
let noun = lower_text(cap(m, 3));
match TRIGGERS.get(noun.as_str()) {
Some(form) => {
format!("{}{} {}", cap(m, 1), ordinal_words(parse_u64(cap(m, 2)), form), cap(m, 3))
}
None => whole(m).to_owned(),
}
})
}
pub(crate) fn normalize_compounds(text: &str) -> String {
static RE: LazyLock<Regex> = LazyLock::new(|| {
compile(concat!(
r"(^|[^\d])(\d+)-(?!(?:ший|ими|им|ім|ою|ій|ше|ша|ге|га|тє|тя|го|му|й|м|а|у|е|х)",
r"(?:[^А-Яа-яЄєІіЇїҐґ]|$))([^0-9A-Za-z\s,.;:!?()]+)"
))
});
sub(text, &RE, |m| {
let mut prefix = String::new();
for word in split_words(&number_to_words(parse_u64(cap(m, 2)))) {
if let Some(form) = COMPOUND_PREFIX_FORMS.get(word.as_str()) {
prefix.push_str(form);
} else if let Some(forms) = CASE_FORMS.get(word.as_str()) {
prefix.push_str(forms[0]);
} else {
prefix.push_str(&word);
}
}
format!("{}{prefix}{}", cap(m, 1), cap(m, 3))
})
}