mf2-locale-data 2.0.0

CLDR locale data for Rust MF2 catalogs (build side): all-locale plural, direction, number, currency and unit tables and the LOCALE entries built from them.
Documentation
//! Plural rules (`plans/05-tooling.md` §7, `plans/02-catalog-format.md`
//! §4.1): the UTS #35 parser, the canonical encoder, and the shipped
//! all-locale table with CLDR's lookup (subtag truncation, then root).

mod encode;
mod rule;
mod samples;

use std::collections::BTreeMap;
use std::sync::OnceLock;

use mf2_catalog::format::locale_key;

pub use encode::{encode, encode_rule};
pub use rule::{Category, Condition, Operand, Relation, Rule, parse_rule};
pub use samples::{SampleItem, SampleList, is_sample_value, parse_samples};

use crate::error::Error;

/// Cardinal or ordinal rules.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub enum PluralKind {
    Cardinal,
    Ordinal,
}

impl PluralKind {
    /// Both kinds.
    pub const ALL: [PluralKind; 2] = [PluralKind::Cardinal, PluralKind::Ordinal];

    /// `"cardinal"` or `"ordinal"`.
    pub const fn name(self) -> &'static str {
        match self {
            PluralKind::Cardinal => "cardinal",
            PluralKind::Ordinal => "ordinal",
        }
    }

    /// The LOCALE entry key (`plural.cardinal` = 1, `plural.ordinal` = 2).
    pub const fn locale_key(self) -> u32 {
        match self {
            PluralKind::Cardinal => locale_key::PLURAL_CARDINAL,
            PluralKind::Ordinal => locale_key::PLURAL_ORDINAL,
        }
    }

    fn from_name(s: &str) -> Option<PluralKind> {
        PluralKind::ALL.into_iter().find(|k| k.name() == s)
    }
}

/// The shipped table: `data/plurals.txt`, generated by `cargo xtask
/// locale-data` from the vendored CLDR JSON.
const TABLE: &str = include_str!("../data/plurals.txt");

/// The root locale's key in CLDR's plural data.
const ROOT: &str = "und";

struct Table {
    rules: BTreeMap<(PluralKind, String), Vec<Rule>>,
}

fn parse_table(text: &str) -> Result<Table, Error> {
    let mut rules: BTreeMap<(PluralKind, String), Vec<Rule>> = BTreeMap::new();
    for (i, line) in text.lines().enumerate() {
        let line_no = i + 1;
        let bad = |message: &str| Error::Table {
            line: line_no,
            message: message.to_owned(),
        };
        if line.is_empty() || line.starts_with('#') || line.starts_with("cldr ") {
            continue;
        }
        let mut fields = line.splitn(4, ' ');
        let (Some(kind), Some(locale), Some(category), text) =
            (fields.next(), fields.next(), fields.next(), fields.next())
        else {
            return Err(bad("expected `kind locale category rule`"));
        };
        let kind = PluralKind::from_name(kind).ok_or_else(|| bad("unknown kind"))?;
        let category = Category::from_name(category)?;
        let rule = parse_rule(category, text.unwrap_or("")).map_err(|source| Error::Rule {
            kind: kind.name(),
            locale: locale.to_owned(),
            category: category.as_str().to_owned(),
            source,
        })?;
        rules
            .entry((kind, locale.to_owned()))
            .or_default()
            .push(rule);
    }
    Ok(Table { rules })
}

fn table() -> Result<&'static Table, Error> {
    static PARSED: OnceLock<Result<Table, String>> = OnceLock::new();
    match PARSED.get_or_init(|| parse_table(TABLE).map_err(|e| e.to_string())) {
        Ok(t) => Ok(t),
        Err(message) => Err(Error::Table {
            line: 0,
            message: message.clone(),
        }),
    }
}

/// One locale's rules of one kind, as CLDR resolves them.
#[derive(Clone, Copy, Debug)]
pub struct LocaleRules {
    /// The CLDR locale whose rules apply (after subtag truncation; `und` is
    /// root: every number is `other`).
    pub locale: &'static str,
    /// The rules, `other` included when CLDR lists it.
    pub rules: &'static [Rule],
}

/// The rules of `kind` for `locale` (a BCP 47 tag; `_` is read as `-`, case
/// is ignored): its own, else those of its truncated tag (`pt-PT` → `pt`),
/// else root's (`und`).
pub fn plural_rules(kind: PluralKind, locale: &str) -> Result<LocaleRules, Error> {
    let t = table()?;
    let tag = locale.replace('_', "-");
    let mut cut = tag.as_str();
    loop {
        let found = t
            .rules
            .range((kind, String::new())..)
            .take_while(|((k, _), _)| *k == kind)
            .find(|((_, l), _)| l.eq_ignore_ascii_case(cut));
        if let Some(((_, l), rules)) = found {
            return Ok(LocaleRules {
                locale: l.as_str(),
                rules: rules.as_slice(),
            });
        }
        match cut.rfind('-') {
            Some(at) => cut = &cut[..at],
            None if cut == ROOT => {
                return Ok(LocaleRules {
                    locale: ROOT,
                    rules: &[],
                });
            }
            None => cut = ROOT,
        }
    }
}

/// The encoded LOCALE entry of `kind` for `locale` (§4.1; empty when every
/// number is `other`).
pub fn plural_entry(kind: PluralKind, locale: &str) -> Result<Vec<u8>, Error> {
    Ok(encode(plural_rules(kind, locale)?.rules))
}

/// The LOCALE entries for `locale`, one per kind in `kinds`, as
/// `mf2_catalog::writer::Options::locale_entries` takes them.
pub fn plural_locale_entries(
    locale: &str,
    kinds: &[PluralKind],
) -> Result<Vec<(u32, Vec<u8>)>, Error> {
    let mut out = Vec::new();
    for &kind in kinds {
        out.push((kind.locale_key(), plural_entry(kind, locale)?));
    }
    out.sort_by_key(|(k, _)| *k);
    out.dedup_by_key(|(k, _)| *k);
    Ok(out)
}

/// Every CLDR locale with rules of `kind`, bytewise ascending.
pub fn plural_locales(kind: PluralKind) -> Result<Vec<&'static str>, Error> {
    let t = table()?;
    Ok(t.rules
        .keys()
        .filter(|(k, _)| *k == kind)
        .map(|(_, l)| l.as_str())
        .collect())
}