normalizer-tr 0.4.0

Bounded Turkish text-to-speech normalization with original-source diagnostics
Documentation
use crate::{
    FallbackClass, IssueCategory,
    model::{Clock, Date, TemporalFailure, TemporalPreference, Value},
    morphology::Inflection,
    verbalize::{date_spoken, time_spoken},
};

use super::{
    context::{DATE_CUES, TIME_CUES, cue_key, is_clock_word},
    scan::{Token, whitespace_between},
};
use crate::domain::numeric::split_suffix;

pub(super) fn date(text: &str, permitted: bool) -> Result<Value, TemporalFailure> {
    let (base, suffix) = split_suffix(text).ok_or(IssueCategory::InvalidExpression)?;
    let date = Date::parse(base).ok_or(IssueCategory::InvalidExpression)?;
    if suffix.is_some() && !date.dotted() {
        return Err(IssueCategory::Unsupported.into());
    }
    if let Some(suffix) = suffix
        && date_spoken(date).source_suffix(Inflection::Locative) != suffix
    {
        return Err(IssueCategory::InvalidExpression.into());
    }
    if !permitted {
        return Err(TemporalFailure {
            category: IssueCategory::Ambiguous,
            preference: Some(TemporalPreference::Date(date, suffix.is_some())),
        });
    }
    Ok(Value::Date(date, suffix.is_some()))
}

pub(super) fn time(text: &str, permitted: bool) -> Result<Value, TemporalFailure> {
    let (base, suffix) = split_suffix(text).ok_or(IssueCategory::InvalidExpression)?;
    let time = Clock::parse(base).ok_or(IssueCategory::InvalidExpression)?;
    if let Some(suffix) = suffix
        && time_spoken(time).source_suffix(Inflection::Locative) != suffix
    {
        return Err(IssueCategory::InvalidExpression.into());
    }
    if !permitted {
        return Err(TemporalFailure {
            category: IssueCategory::Ambiguous,
            preference: Some(TemporalPreference::Time(time, suffix.is_some())),
        });
    }
    Ok(Value::Time(time, suffix.is_some()))
}

fn cue(text: &str, tokens: &[Token<'_>], index: usize, allowed: &[&str]) -> bool {
    let Some(mut previous_index) = index.checked_sub(1) else {
        return false;
    };
    let adjacent = |start, end| start == end || whitespace_between(text, start, end);
    if !adjacent(tokens[previous_index].range.end, tokens[index].range.start) {
        return false;
    }
    if tokens[previous_index].text == ":" {
        let Some(word_index) = previous_index.checked_sub(1) else {
            return false;
        };
        if !whitespace_between(
            text,
            tokens[word_index].range.end,
            tokens[previous_index].range.start,
        ) || tokens[word_index].text.contains(':')
        {
            return false;
        }
        previous_index = word_index;
    }
    allowed.contains(&cue_key(tokens[previous_index].text).as_str())
}

fn frame(text: &str, tokens: &[Token<'_>], index: usize) -> bool {
    let (Some(date_token), Some(cue_token), Some(time_token)) = (
        tokens.get(index),
        tokens.get(index + 1),
        tokens.get(index + 2),
    ) else {
        return false;
    };
    matches!(date(date_token.text, true), Ok(Value::Date(date, true)) if date.dotted())
        && is_clock_word(cue_token.text)
        && matches!(time(time_token.text, true), Ok(Value::Time(_, true)))
        && whitespace_between(text, date_token.range.end, cue_token.range.start)
        && whitespace_between(text, cue_token.range.end, time_token.range.start)
}

pub(super) fn recognize(
    text: &str,
    tokens: &[Token<'_>],
    index: usize,
) -> Option<(Result<Value, TemporalFailure>, FallbackClass)> {
    let token = tokens[index].text;
    let (base, _) = split_suffix(token)?;
    let dots = base.bytes().filter(|b| *b == b'.').count();
    let hyphens = base.bytes().filter(|b| *b == b'-').count();
    if dots == 2 || (hyphens == 2 && base.len() >= 8) {
        if dots == 2
            && crate::numerals::Number::parse(base).is_some_and(|n| n.grouped())
            && !cue(text, tokens, index, DATE_CUES)
        {
            return None;
        }
        return Some((
            date(
                token,
                frame(text, tokens, index) || cue(text, tokens, index, DATE_CUES),
            ),
            FallbackClass::Date,
        ));
    }
    if base.contains(':') || (dots == 1 && cue(text, tokens, index, TIME_CUES)) {
        return Some((
            time(token, cue(text, tokens, index, TIME_CUES)),
            FallbackClass::Time,
        ));
    }
    None
}