use crate::error::{Code, Result, TimeError};
use crate::tier::{Tier, TierName, NAMED};
#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Debug, Default)]
#[non_exhaustive]
pub enum LocaleId {
#[default]
En,
Ru,
}
impl LocaleId {
pub const fn tag(self) -> &'static str {
match self {
LocaleId::En => "en",
LocaleId::Ru => "ru",
}
}
pub const ALL: &'static [LocaleId] = &[LocaleId::En, LocaleId::Ru];
pub fn parse(tag: &str) -> Result<LocaleId> {
LocaleId::ALL
.iter()
.copied()
.find(|l| l.tag() == tag)
.ok_or(TimeError::with_context(
Code::E0010,
"unknown locale; shipped locales are en and ru",
))
}
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
#[non_exhaustive]
pub struct Names {
pub singular: &'static str,
pub plural: &'static str,
pub short: Option<&'static str>,
}
const fn n(singular: &'static str, plural: &'static str) -> Names {
Names {
singular,
plural,
short: None,
}
}
const fn ns(singular: &'static str, plural: &'static str, short: &'static str) -> Names {
Names {
singular,
plural,
short: Some(short),
}
}
const EN: &[(TierName, Names)] = &[
(TierName::Deep, n("deep", "deeps")),
(TierName::Drift, n("drift", "drifts")),
(TierName::Span, n("span", "spans")),
(TierName::Sweep, n("sweep", "sweeps")),
(TierName::Arc, n("arc", "arcs")),
(TierName::Beat, n("beat", "beats")),
(TierName::Flicker, n("flicker", "flickers")),
(TierName::Glint, n("glint", "glints")),
(TierName::Spark, n("spark", "sparks")),
(TierName::Tick, n("tick", "ticks")),
];
const RU: &[(TierName, Names)] = &[
(TierName::Deep, ns("глубь", "глуби", "гл")),
(TierName::Drift, ns("дрейф", "дрейфы", "др")),
(TierName::Span, ns("пролёт", "пролёты", "пр")),
(TierName::Sweep, ns("обход", "обходы", "обх")),
(TierName::Arc, ns("дуга", "дуги", "ду")),
(TierName::Beat, ns("бой", "бои", "бо")),
(TierName::Flicker, ns("мерцание", "мерцания", "мц")),
(TierName::Glint, ns("блик", "блики", "бл")),
(TierName::Spark, ns("искра", "искры", "ис")),
(TierName::Tick, ns("тик", "тики", "ти")),
];
pub const DETECTABLE: &str = "бгджзийлпфцчшщъыьэюя";
pub const SHORT_FORMS_ARE_DETECTABLE: &str =
"Every locale short form contains at least one letter with no Latin homoglyph \
(ucal_core::locale::DETECTABLE), so it cannot be mistaken for Latin text; no \
two collide, and none is another's reversal.";
pub const fn table(locale: LocaleId) -> &'static [(TierName, Names)] {
match locale {
LocaleId::En => EN,
LocaleId::Ru => RU,
}
}
pub fn names_of(locale: LocaleId, tier: Tier) -> Option<Names> {
let key = crate::tier::name_of(tier)?;
table(locale)
.iter()
.find(|(k, _)| *k == key)
.map(|(_, v)| *v)
}
#[cfg(feature = "alloc")]
pub fn display(locale: LocaleId, tier: Tier) -> alloc::string::String {
use alloc::string::ToString;
match names_of(locale, tier) {
Some(n) => n.singular.to_string(),
None => tier.to_string(),
}
}
pub fn resolve(locale: LocaleId, s: &str) -> Result<Tier> {
let t = s.trim();
if let Some(k) = t.strip_prefix('T') {
if let Ok(idx) = k.parse::<i8>() {
return Tier::new(idx);
}
}
if let Some(e) = t.strip_prefix("5^") {
if let Ok(exp) = e.parse::<u32>() {
return Tier::from_exponent(exp);
}
}
let lowered = ascii_lower(t);
for (key, names) in table(locale) {
if eq_fold(names.singular, &lowered) || eq_fold(names.plural, &lowered) {
return tier_of_name(*key);
}
if let Some(short) = names.short {
if eq_fold(short, &lowered) {
return tier_of_name(*key);
}
}
}
for (k, key) in NAMED {
if eq_fold(key.key(), &lowered) {
return Tier::new(*k);
}
}
Err(TimeError::with_context(
Code::E0011,
"unknown tier name; try a locale name, a stable key, T<k>, or 5^e",
))
}
fn tier_of_name(key: TierName) -> Result<Tier> {
NAMED
.iter()
.find(|(_, k)| *k == key)
.map(|(idx, _)| Tier::new(*idx))
.unwrap_or(Err(TimeError::new(Code::E0011)))
}
fn eq_fold(candidate: &str, lowered_input: &str) -> bool {
if candidate.is_ascii() {
candidate.eq_ignore_ascii_case(lowered_input)
} else {
candidate == lowered_input
}
}
#[cfg(feature = "alloc")]
fn ascii_lower(s: &str) -> alloc::string::String {
s.chars()
.map(|c| if c.is_ascii() { c.to_ascii_lowercase() } else { c })
.collect()
}
#[cfg(not(feature = "alloc"))]
fn ascii_lower(s: &str) -> &str {
s
}
pub fn validate(locale: LocaleId) -> Result<()> {
let t = table(locale);
for (_, key) in NAMED {
let count = t.iter().filter(|(k, _)| k == key).count();
if count != 1 {
return Err(TimeError::with_context(
Code::E0010,
"locale table does not cover every named tier exactly once",
));
}
}
for (i, (_, a)) in t.iter().enumerate() {
for (_, b) in t.iter().skip(i + 1) {
if a.singular == b.singular
|| a.plural == b.plural
|| a.singular == b.plural
|| a.plural == b.singular
{
return Err(TimeError::with_context(
Code::E0011,
"duplicate name in the active locale table",
));
}
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn every_shipped_locale_is_valid() {
for l in LocaleId::ALL {
validate(*l).unwrap_or_else(|e| panic!("locale {} is invalid: {e}", l.tag()));
}
}
#[test]
fn every_locale_covers_every_named_tier() {
for l in LocaleId::ALL {
assert_eq!(table(*l).len(), NAMED.len(), "locale {}", l.tag());
for (k, _) in NAMED {
let tier = Tier::new(*k).unwrap();
assert!(
names_of(*l, tier).is_some(),
"locale {} is missing T{k}",
l.tag()
);
}
}
}
#[test]
fn unnamed_tiers_stay_unnamed_in_every_locale() {
for l in LocaleId::ALL {
for k in [6i8, 10, 32, -4, -8, -11] {
let tier = Tier::new(k).unwrap();
assert!(names_of(*l, tier).is_none(), "T{k} in {}", l.tag());
assert_eq!(display(*l, tier), alloc::format!("T{k}"));
}
}
}
#[test]
fn names_resolve_in_both_locales() {
assert_eq!(resolve(LocaleId::En, "deep").unwrap(), Tier::DEEP);
assert_eq!(resolve(LocaleId::En, "deeps").unwrap(), Tier::DEEP);
assert_eq!(resolve(LocaleId::Ru, "глубь").unwrap(), Tier::DEEP);
assert_eq!(resolve(LocaleId::Ru, "глуби").unwrap(), Tier::DEEP);
assert_eq!(resolve(LocaleId::Ru, "бой").unwrap(), Tier::BEAT);
assert_eq!(resolve(LocaleId::Ru, "тик").unwrap(), Tier::TICK);
}
#[test]
fn the_stable_key_works_in_any_locale() {
for l in LocaleId::ALL {
assert_eq!(resolve(*l, "beat").unwrap(), Tier::BEAT);
assert_eq!(resolve(*l, "deep").unwrap(), Tier::DEEP);
}
}
#[test]
fn index_and_exponent_notation_work_wherever_a_name_does() {
for l in LocaleId::ALL {
assert_eq!(resolve(*l, "T0").unwrap(), Tier::BEAT);
assert_eq!(resolve(*l, "T-12").unwrap(), Tier::TICK);
assert_eq!(resolve(*l, "5^60").unwrap(), Tier::BEAT);
assert_eq!(resolve(*l, "5^220").unwrap(), Tier::new(32).unwrap());
assert_eq!(resolve(*l, "T7").unwrap(), Tier::new(7).unwrap());
assert_eq!(resolve(*l, "5^95").unwrap(), Tier::new(7).unwrap());
}
}
#[test]
fn ascii_names_fold_case_but_cyrillic_is_taken_as_written() {
assert_eq!(resolve(LocaleId::En, "DEEP").unwrap(), Tier::DEEP);
assert_eq!(resolve(LocaleId::En, "Deeps").unwrap(), Tier::DEEP);
assert!(resolve(LocaleId::Ru, "глубь").is_ok());
assert!(resolve(LocaleId::Ru, "ГЛУБЬ").is_err());
}
#[test]
fn unknown_names_are_e0011() {
for l in LocaleId::ALL {
assert_eq!(resolve(*l, "aeon").unwrap_err().code, Code::E0011);
assert_eq!(resolve(*l, "").unwrap_err().code, Code::E0011);
}
assert_eq!(resolve(LocaleId::En, "5^61").unwrap_err().code, Code::E0080);
}
#[test]
fn locale_tags_round_trip() {
for l in LocaleId::ALL {
assert_eq!(LocaleId::parse(l.tag()).unwrap(), *l);
}
assert_eq!(LocaleId::parse("xx").unwrap_err().code, Code::E0010);
}
#[test]
fn no_calendar_units_appear_in_the_ladder() {
for l in LocaleId::ALL {
for (_, names) in table(*l) {
for n in [names.singular, names.plural] {
for forbidden in ["day", "year", "month", "week", "hour", "second"] {
assert_ne!(n, forbidden, "locale {} names a calendar unit", l.tag());
}
}
}
}
}
#[test]
fn names_avoid_the_content_appendix_d_rules_out() {
for l in LocaleId::ALL {
for (_, names) in table(*l) {
let s = names.singular;
for prefix in ["kilo", "mega", "giga", "tera", "milli", "micro", "nano"] {
assert!(!s.starts_with(prefix), "{s} carries a numeric prefix");
}
assert!(!s.is_empty());
assert!(!s.contains(' '), "{s} is not a single word");
}
}
}
fn shorts() -> alloc::vec::Vec<(LocaleId, &'static str)> {
let mut v = alloc::vec::Vec::new();
for loc in LocaleId::ALL {
for (_, names) in table(*loc) {
if let Some(s) = names.short {
v.push((*loc, s));
}
}
}
v
}
#[test]
fn every_short_form_is_detectably_not_latin() {
for (loc, s) in shorts() {
assert!(
s.chars().any(|c| DETECTABLE.contains(c)),
"{}: `{s}` is entirely Latin-homoglyphic",
loc.tag()
);
}
}
#[test]
fn no_two_short_forms_collide_or_reverse_each_other() {
for loc in LocaleId::ALL {
let v: alloc::vec::Vec<&str> = table(*loc)
.iter()
.filter_map(|(_, n)| n.short)
.collect();
for (i, a) in v.iter().enumerate() {
for (j, b) in v.iter().enumerate() {
if i == j {
continue;
}
assert_ne!(a, b, "{}: `{a}` is used twice", loc.tag());
let rev: alloc::string::String = b.chars().rev().collect();
assert_ne!(
*a, rev,
"{}: `{a}` and `{b}` are reversals of one another",
loc.tag()
);
}
}
}
}
#[test]
fn a_short_form_resolves_wherever_a_name_does() {
for (_, names) in table(LocaleId::Ru) {
let Some(short) = names.short else { continue };
let by_short = resolve(LocaleId::Ru, short).expect("short form resolves");
let by_name = resolve(LocaleId::Ru, names.singular).expect("name resolves");
assert_eq!(by_short, by_name, "`{short}` and `{}` differ", names.singular);
}
}
#[test]
fn short_forms_do_not_leak_across_locales() {
assert!(resolve(LocaleId::En, "пр").is_err());
assert!(resolve(LocaleId::En, "бо").is_err());
assert!(table(LocaleId::En).iter().all(|(_, n)| n.short.is_none()));
}
#[test]
fn t3_is_the_span_of_a_bridge() {
let names = table(LocaleId::Ru)
.iter()
.find(|(k, _)| *k == TierName::Span)
.map(|(_, n)| *n)
.expect("T3 is named in ru");
assert_eq!(names.singular, "пролёт");
assert_eq!(names.short, Some("пр"));
assert!(
!"срок".chars().any(|c| DETECTABLE.contains(c)),
"срок gained a detectable letter; the reason for the change moved"
);
}
}