Skip to main content

ucal_core/
locale.rs

1//! Locale tables for tier names (Appendix D, Rule N).
2//!
3//! # Names are display-only
4//!
5//! Rule N: a tier's canonical identity is its **exponent**. Everything here is a
6//! display and parse alias, and nothing in the library decides behaviour from a
7//! name. Adding a locale therefore cannot change what any value means — which is
8//! what makes D-20's position tenable, that naming the unnamed tiers is a locale
9//! change rather than a specification change.
10//!
11//! Rule N also requires `T[k]` and `5^e` notation to be accepted *wherever a name
12//! is accepted*, so every resolver here falls back to them.
13//!
14//! # Why the names are what they are
15//!
16//! Appendix D records the criterion: short, concrete motion words with no
17//! mythological, religious, national or numeric-prefix content. That rules out
18//! the obvious candidates — no "aeon", no "epoch", no "kilo-" or "mega-" — and it
19//! is why the ladder reads *deep, drift, span, sweep, arc, beat, flicker, glint,
20//! spark* rather than anything more familiar. Familiarity here would be a defect:
21//! the scale is not a second and should not sound like one.
22//!
23//! Calendar unit names — day, year, cycle — are deliberately **absent**. They
24//! belong to a body's calendar and are declared with it (§9), not to the
25//! universal ladder.
26
27use crate::error::{Code, Result, TimeError};
28use crate::tier::{Tier, TierName, NAMED};
29
30/// A shipped locale (D-19).
31#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Debug, Default)]
32#[non_exhaustive]
33pub enum LocaleId {
34    /// English. The stable keys double as the English names.
35    #[default]
36    En,
37    /// Russian.
38    Ru,
39}
40
41impl LocaleId {
42    /// The BCP-47-ish tag.
43    pub const fn tag(self) -> &'static str {
44        match self {
45            LocaleId::En => "en",
46            LocaleId::Ru => "ru",
47        }
48    }
49
50    /// Every shipped locale.
51    pub const ALL: &'static [LocaleId] = &[LocaleId::En, LocaleId::Ru];
52
53    /// Resolve a locale tag.
54    pub fn parse(tag: &str) -> Result<LocaleId> {
55        LocaleId::ALL
56            .iter()
57            .copied()
58            .find(|l| l.tag() == tag)
59            .ok_or(TimeError::with_context(
60                Code::E0010,
61                "unknown locale; shipped locales are en and ru",
62            ))
63    }
64}
65
66/// A tier's names in one locale: singular and plural.
67#[derive(Clone, Copy, PartialEq, Eq, Debug)]
68pub struct Names {
69    /// One of them.
70    pub singular: &'static str,
71    /// More than one.
72    pub plural: &'static str,
73}
74
75const fn n(singular: &'static str, plural: &'static str) -> Names {
76    Names { singular, plural }
77}
78
79/// The English table. The stable keys and the display names coincide, which is
80/// why `en` needs no aliasing.
81const EN: &[(TierName, Names)] = &[
82    (TierName::Deep, n("deep", "deeps")),
83    (TierName::Drift, n("drift", "drifts")),
84    (TierName::Span, n("span", "spans")),
85    (TierName::Sweep, n("sweep", "sweeps")),
86    (TierName::Arc, n("arc", "arcs")),
87    (TierName::Beat, n("beat", "beats")),
88    (TierName::Flicker, n("flicker", "flickers")),
89    (TierName::Glint, n("glint", "glints")),
90    (TierName::Spark, n("spark", "sparks")),
91    (TierName::Tick, n("tick", "ticks")),
92];
93
94/// The Russian table (Appendix D, D-19).
95const RU: &[(TierName, Names)] = &[
96    (TierName::Deep, n("глубь", "глуби")),
97    (TierName::Drift, n("дрейф", "дрейфы")),
98    (TierName::Span, n("срок", "сроки")),
99    (TierName::Sweep, n("обход", "обходы")),
100    (TierName::Arc, n("дуга", "дуги")),
101    (TierName::Beat, n("бой", "бои")),
102    (TierName::Flicker, n("мерцание", "мерцания")),
103    (TierName::Glint, n("блик", "блики")),
104    (TierName::Spark, n("искра", "искры")),
105    (TierName::Tick, n("тик", "тики")),
106];
107
108/// The table for a locale.
109pub const fn table(locale: LocaleId) -> &'static [(TierName, Names)] {
110    match locale {
111        LocaleId::En => EN,
112        LocaleId::Ru => RU,
113    }
114}
115
116/// The names of a tier in a locale, if the tier is named at all.
117///
118/// Unnamed tiers return `None` and are addressed by index (D-20).
119pub fn names_of(locale: LocaleId, tier: Tier) -> Option<Names> {
120    let key = crate::tier::name_of(tier)?;
121    table(locale)
122        .iter()
123        .find(|(k, _)| *k == key)
124        .map(|(_, v)| *v)
125}
126
127/// A tier's display name in a locale, or its `T[k]` form if unnamed.
128///
129/// Always returns something printable, because Rule N guarantees the index
130/// notation is valid wherever a name is.
131#[cfg(feature = "alloc")]
132pub fn display(locale: LocaleId, tier: Tier) -> alloc::string::String {
133    use alloc::string::ToString;
134    match names_of(locale, tier) {
135        Some(n) => n.singular.to_string(),
136        None => tier.to_string(),
137    }
138}
139
140/// Resolve a tier from a locale name, a stable key, `T[k]`, or `5^e` (Rule N).
141///
142/// Singular and plural both resolve, and matching is case-insensitive for ASCII;
143/// a user who types `Deeps` means the same tier as one who types `deep`.
144pub fn resolve(locale: LocaleId, s: &str) -> Result<Tier> {
145    let t = s.trim();
146
147    // Index and exponent notation, accepted wherever a name is (Rule N).
148    if let Some(k) = t.strip_prefix('T') {
149        if let Ok(idx) = k.parse::<i8>() {
150            return Tier::new(idx);
151        }
152    }
153    if let Some(e) = t.strip_prefix("5^") {
154        if let Ok(exp) = e.parse::<u32>() {
155            return Tier::from_exponent(exp);
156        }
157    }
158
159    // Locale names, then the stable keys, so a key always works in any locale.
160    let lowered = ascii_lower(t);
161    for (key, names) in table(locale) {
162        if eq_fold(names.singular, &lowered) || eq_fold(names.plural, &lowered) {
163            return tier_of_name(*key);
164        }
165    }
166    for (k, key) in NAMED {
167        if eq_fold(key.key(), &lowered) {
168            return Tier::new(*k);
169        }
170    }
171    Err(TimeError::with_context(
172        Code::E0011,
173        "unknown tier name; try a locale name, a stable key, T<k>, or 5^e",
174    ))
175}
176
177fn tier_of_name(key: TierName) -> Result<Tier> {
178    NAMED
179        .iter()
180        .find(|(_, k)| *k == key)
181        .map(|(idx, _)| Tier::new(*idx))
182        .unwrap_or(Err(TimeError::new(Code::E0011)))
183}
184
185/// Case-insensitive comparison for ASCII, exact for everything else.
186///
187/// Deliberately not a full Unicode case fold: Russian tier names are compared as
188/// written. Case-folding Cyrillic correctly needs tables this crate has no reason
189/// to carry, and getting it half-right would be worse than not doing it.
190fn eq_fold(candidate: &str, lowered_input: &str) -> bool {
191    if candidate.is_ascii() {
192        candidate.eq_ignore_ascii_case(lowered_input)
193    } else {
194        candidate == lowered_input
195    }
196}
197
198#[cfg(feature = "alloc")]
199fn ascii_lower(s: &str) -> alloc::string::String {
200    s.chars()
201        .map(|c| if c.is_ascii() { c.to_ascii_lowercase() } else { c })
202        .collect()
203}
204
205#[cfg(not(feature = "alloc"))]
206fn ascii_lower(s: &str) -> &str {
207    s
208}
209
210/// Validate a locale table: every name distinct, every named tier covered.
211///
212/// Rule N makes a collision within an active table `UCAL-E0011`. Checked rather
213/// than assumed, because a collision would make a name ambiguous on input while
214/// still looking fine on output.
215pub fn validate(locale: LocaleId) -> Result<()> {
216    let t = table(locale);
217    // Every named tier must appear exactly once.
218    for (_, key) in NAMED {
219        let count = t.iter().filter(|(k, _)| k == key).count();
220        if count != 1 {
221            return Err(TimeError::with_context(
222                Code::E0010,
223                "locale table does not cover every named tier exactly once",
224            ));
225        }
226    }
227    // No two display names may collide, singular or plural.
228    for (i, (_, a)) in t.iter().enumerate() {
229        for (_, b) in t.iter().skip(i + 1) {
230            if a.singular == b.singular
231                || a.plural == b.plural
232                || a.singular == b.plural
233                || a.plural == b.singular
234            {
235                return Err(TimeError::with_context(
236                    Code::E0011,
237                    "duplicate name in the active locale table",
238                ));
239            }
240        }
241    }
242    Ok(())
243}
244
245#[cfg(test)]
246mod tests {
247    use super::*;
248
249    #[test]
250    fn every_shipped_locale_is_valid() {
251        for l in LocaleId::ALL {
252            validate(*l).unwrap_or_else(|e| panic!("locale {} is invalid: {e}", l.tag()));
253        }
254    }
255
256    #[test]
257    fn every_locale_covers_every_named_tier() {
258        // §13.5: the tier table and the locale table come from one source, so a
259        // tier cannot be named in one and missing from the other.
260        for l in LocaleId::ALL {
261            assert_eq!(table(*l).len(), NAMED.len(), "locale {}", l.tag());
262            for (k, _) in NAMED {
263                let tier = Tier::new(*k).unwrap();
264                assert!(
265                    names_of(*l, tier).is_some(),
266                    "locale {} is missing T{k}",
267                    l.tag()
268                );
269            }
270        }
271    }
272
273    #[test]
274    fn unnamed_tiers_stay_unnamed_in_every_locale() {
275        // D-20: tiers above T5 and below T-3 are addressable by index only.
276        for l in LocaleId::ALL {
277            for k in [6i8, 10, 32, -4, -8, -11] {
278                let tier = Tier::new(k).unwrap();
279                assert!(names_of(*l, tier).is_none(), "T{k} in {}", l.tag());
280                // ...but they always have a printable form.
281                assert_eq!(display(*l, tier), alloc::format!("T{k}"));
282            }
283        }
284    }
285
286    #[test]
287    fn names_resolve_in_both_locales() {
288        assert_eq!(resolve(LocaleId::En, "deep").unwrap(), Tier::DEEP);
289        assert_eq!(resolve(LocaleId::En, "deeps").unwrap(), Tier::DEEP);
290        assert_eq!(resolve(LocaleId::Ru, "глубь").unwrap(), Tier::DEEP);
291        assert_eq!(resolve(LocaleId::Ru, "глуби").unwrap(), Tier::DEEP);
292        assert_eq!(resolve(LocaleId::Ru, "бой").unwrap(), Tier::BEAT);
293        assert_eq!(resolve(LocaleId::Ru, "тик").unwrap(), Tier::TICK);
294    }
295
296    #[test]
297    fn the_stable_key_works_in_any_locale() {
298        // The key is the identity across locales, so `beat` resolves under `ru`
299        // even though the Russian display name is `бой`.
300        for l in LocaleId::ALL {
301            assert_eq!(resolve(*l, "beat").unwrap(), Tier::BEAT);
302            assert_eq!(resolve(*l, "deep").unwrap(), Tier::DEEP);
303        }
304    }
305
306    #[test]
307    fn index_and_exponent_notation_work_wherever_a_name_does() {
308        // Rule N states this explicitly.
309        for l in LocaleId::ALL {
310            assert_eq!(resolve(*l, "T0").unwrap(), Tier::BEAT);
311            assert_eq!(resolve(*l, "T-12").unwrap(), Tier::TICK);
312            assert_eq!(resolve(*l, "5^60").unwrap(), Tier::BEAT);
313            assert_eq!(resolve(*l, "5^220").unwrap(), Tier::new(32).unwrap());
314            // Including for tiers that have no name at all (D-20).
315            assert_eq!(resolve(*l, "T7").unwrap(), Tier::new(7).unwrap());
316            assert_eq!(resolve(*l, "5^95").unwrap(), Tier::new(7).unwrap());
317        }
318    }
319
320    #[test]
321    fn ascii_names_fold_case_but_cyrillic_is_taken_as_written() {
322        assert_eq!(resolve(LocaleId::En, "DEEP").unwrap(), Tier::DEEP);
323        assert_eq!(resolve(LocaleId::En, "Deeps").unwrap(), Tier::DEEP);
324        // Cyrillic is compared as written; folding it correctly needs tables this
325        // crate has no reason to carry.
326        assert!(resolve(LocaleId::Ru, "глубь").is_ok());
327        assert!(resolve(LocaleId::Ru, "ГЛУБЬ").is_err());
328    }
329
330    #[test]
331    fn unknown_names_are_e0011() {
332        for l in LocaleId::ALL {
333            assert_eq!(resolve(*l, "aeon").unwrap_err().code, Code::E0011);
334            assert_eq!(resolve(*l, "").unwrap_err().code, Code::E0011);
335        }
336        // An off-grid exponent is a tier error, not a naming one.
337        assert_eq!(resolve(LocaleId::En, "5^61").unwrap_err().code, Code::E0080);
338    }
339
340    #[test]
341    fn locale_tags_round_trip() {
342        for l in LocaleId::ALL {
343            assert_eq!(LocaleId::parse(l.tag()).unwrap(), *l);
344        }
345        assert_eq!(LocaleId::parse("xx").unwrap_err().code, Code::E0010);
346    }
347
348    #[test]
349    fn no_calendar_units_appear_in_the_ladder() {
350        // Appendix D: day, year and cycle belong to a body's calendar, not to the
351        // universal ladder. A name collision with one would invite exactly the
352        // conflation §8.3 exists to prevent.
353        for l in LocaleId::ALL {
354            for (_, names) in table(*l) {
355                for n in [names.singular, names.plural] {
356                    for forbidden in ["day", "year", "month", "week", "hour", "second"] {
357                        assert_ne!(n, forbidden, "locale {} names a calendar unit", l.tag());
358                    }
359                }
360            }
361        }
362    }
363
364    #[test]
365    fn names_avoid_the_content_appendix_d_rules_out() {
366        // "no mythological, religious, national, or numeric-prefix content".
367        for l in LocaleId::ALL {
368            for (_, names) in table(*l) {
369                let s = names.singular;
370                for prefix in ["kilo", "mega", "giga", "tera", "milli", "micro", "nano"] {
371                    assert!(!s.starts_with(prefix), "{s} carries a numeric prefix");
372                }
373                assert!(!s.is_empty());
374                // Short: every name is a single word.
375                assert!(!s.contains(' '), "{s} is not a single word");
376            }
377        }
378    }
379}