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)]
pub struct Names {
pub singular: &'static str,
pub plural: &'static str,
}
const fn n(singular: &'static str, plural: &'static str) -> Names {
Names { singular, plural }
}
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, n("глубь", "глуби")),
(TierName::Drift, n("дрейф", "дрейфы")),
(TierName::Span, n("срок", "сроки")),
(TierName::Sweep, n("обход", "обходы")),
(TierName::Arc, n("дуга", "дуги")),
(TierName::Beat, n("бой", "бои")),
(TierName::Flicker, n("мерцание", "мерцания")),
(TierName::Glint, n("блик", "блики")),
(TierName::Spark, n("искра", "искры")),
(TierName::Tick, n("тик", "тики")),
];
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);
}
}
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");
}
}
}
}