normalizer-tr 0.4.0

Bounded Turkish text-to-speech normalization with original-source diagnostics
Documentation
mod boundaries;
mod context;
mod readers;
mod scan;
mod symbols;
mod temporal;

use crate::{
    Hint, LimitKind, MAX_CANDIDATES, NormalizeError, SourceRange, WorkControl, fallback,
    model::Value, resources::Resources, source_map::SourceMap,
};
use boundaries::{Boundaries, Claim, overlaps_hint};
use readers::Context;
pub(crate) use symbols::supplement;

pub(crate) struct Candidate {
    pub(crate) range: SourceRange,
    pub(crate) reading: Reading,
}

pub(crate) enum Reading {
    Resolved(Value),
    Unresolved(fallback::Request),
}

fn push(
    candidates: &mut Vec<Candidate>,
    claim: Claim,
    reading: Reading,
) -> Result<(), NormalizeError> {
    if candidates.len() == MAX_CANDIDATES {
        return Err(NormalizeError::LimitExceeded(LimitKind::Candidates));
    }
    candidates.push(Candidate {
        range: claim.range,
        reading,
    });
    Ok(())
}

pub(crate) fn collect(
    source: &SourceMap,
    hints: &[Hint],
    resources: &Resources,
    control: &WorkControl,
) -> Result<Vec<Candidate>, NormalizeError> {
    let text = source.text();
    let tokens = scan::tokens(source, resources, control)?;
    let phone_like = scan::phones(text, &tokens, resources, control)?;
    let bounds = Boundaries::new(&tokens, &phone_like);
    let ctx = Context {
        text,
        tokens: &tokens,
    };
    let mut candidates = Vec::new();
    let mut index = 0;
    let mut hint_index = 0;
    let mut role_until = 0;
    while index < tokens.len() {
        control.check()?;
        if let Some(hint) = hints
            .get(hint_index)
            .filter(|h| h.range.start <= tokens[index].range.start)
        {
            let detected = readers::read(&ctx, index, &bounds, index < role_until)
                .map(|(_, end)| bounds.claim(index, end))
                .transpose()?;
            let claim = bounds.hint_claim(*hint, detected.as_ref())?;
            let text = &text[claim.range.start..claim.range.end];
            let value = readers::hint(text, hint.kind).ok_or(NormalizeError::InvalidHint)?;
            if matches!(value, Value::Roman(_)) {
                role_until = ctx.roman_anchor_end(index).unwrap_or(role_until);
            }
            index = claim.next;
            push(&mut candidates, claim, Reading::Resolved(value))?;
            hint_index += 1;
            continue;
        }
        if hints
            .get(hint_index)
            .is_some_and(|h| h.range.start < tokens[index].range.end)
        {
            return Err(NormalizeError::InvalidHint);
        }
        if let Some((reading, end)) = readers::read(&ctx, index, &bounds, index < role_until) {
            let claim = bounds.claim(index, end)?;
            if overlaps_hint(hints, claim.range) {
                return Err(NormalizeError::InvalidHint);
            }
            if matches!(&reading, Reading::Resolved(Value::Roman(_))) {
                role_until = ctx.roman_anchor_end(index).unwrap_or(role_until);
            }
            index = claim.next;
            push(&mut candidates, claim, reading)?;
        } else {
            index += 1;
        }
    }
    if hint_index != hints.len() {
        return Err(NormalizeError::InvalidHint);
    }
    Ok(candidates)
}

#[cfg(test)]
mod tests {
    use super::*;
    #[test]
    fn candidate_limit_accepts_exact_boundary() {
        let mut candidates = Vec::new();
        for _ in 0..MAX_CANDIDATES {
            assert!(
                push(
                    &mut candidates,
                    Claim {
                        range: SourceRange::new(0, 1),
                        next: 1
                    },
                    Reading::Unresolved(fallback::Request::unresolved(
                        "1.234",
                        crate::FallbackClass::Number,
                        crate::IssueCategory::Ambiguous
                    ))
                )
                .is_ok()
            );
        }
        assert!(matches!(
            push(
                &mut candidates,
                Claim {
                    range: SourceRange::new(0, 1),
                    next: 1
                },
                Reading::Unresolved(fallback::Request::unresolved(
                    "1.234",
                    crate::FallbackClass::Number,
                    crate::IssueCategory::Ambiguous
                ))
            ),
            Err(NormalizeError::LimitExceeded(LimitKind::Candidates))
        ));
    }
}