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, plural, and a short form.
67///
68/// `#[non_exhaustive]`: construct one through the crate rather than with a
69/// struct literal. Added in 0.3.0, which already broke literals by introducing
70/// `short`, so the break was paid this release either way.
71#[derive(Clone, Copy, PartialEq, Eq, Debug)]
72#[non_exhaustive]
73pub struct Names {
74    /// One of them.
75    pub singular: &'static str,
76    /// More than one.
77    pub plural: &'static str,
78    /// A two- or three-letter form for tables and prose, or `None`.
79    ///
80    /// Locale-scoped, and that is the whole design. A short form derived from a
81    /// name cannot be universal, because names differ by locale — `bt` for
82    /// *beat* means nothing under `ru`. Scoping it to the locale makes
83    /// invariance structural rather than something to remember: the locale is
84    /// stated, so a form cannot silently mean something else elsewhere.
85    ///
86    /// `en` ships none. `T[k]` and `5^e` are already short, locale-invariant and
87    /// accepted wherever a name is, so an English abbreviation would be a second
88    /// spelling of something that has one.
89    pub short: Option<&'static str>,
90}
91
92const fn n(singular: &'static str, plural: &'static str) -> Names {
93    Names {
94        singular,
95        plural,
96        short: None,
97    }
98}
99
100const fn ns(singular: &'static str, plural: &'static str, short: &'static str) -> Names {
101    Names {
102        singular,
103        plural,
104        short: Some(short),
105    }
106}
107
108/// The English table. The stable keys and the display names coincide, which is
109/// why `en` needs no aliasing.
110const EN: &[(TierName, Names)] = &[
111    (TierName::Deep, n("deep", "deeps")),
112    (TierName::Drift, n("drift", "drifts")),
113    (TierName::Span, n("span", "spans")),
114    (TierName::Sweep, n("sweep", "sweeps")),
115    (TierName::Arc, n("arc", "arcs")),
116    (TierName::Beat, n("beat", "beats")),
117    (TierName::Flicker, n("flicker", "flickers")),
118    (TierName::Glint, n("glint", "glints")),
119    (TierName::Spark, n("spark", "sparks")),
120    (TierName::Tick, n("tick", "ticks")),
121];
122
123/// The Russian table (Appendix D, D-19), with short forms.
124///
125/// # Why every short form carries a letter with no Latin twin
126///
127/// Twelve lowercase Cyrillic letters are pixel-identical to Latin ones in most
128/// terminal fonts: `а с е о р х у к м н в т`. A form built only from those is
129/// indistinguishable from Latin text, and this project already treats visual
130/// ambiguity in a parse surface as a defect to design out rather than document
131/// around — the UCID alphabet drops `I`, `L` and `O` for exactly that reason.
132///
133/// So each form below contains at least one of `б г д ж з и й л п ф ц ч ш щ ъ ы
134/// ь э ю я`, which makes recognising it a detection rather than a guess.
135/// [`SHORT_FORMS_ARE_DETECTABLE`] states the rule and a test enforces it.
136///
137/// Two choices are not arbitrary. `обход` takes three letters because `об` and
138/// `бо` are reversals of one another and `бо` is the beat — the rung read most
139/// often, and the worst place for a pair that differs only in letter order.
140/// `мерцание` takes `мц` rather than `ме`, which is entirely homoglyphic and
141/// would render as the Latin word "me".
142///
143/// # T3 is пролёт, not срок
144///
145/// `срок` was the shipped name and has no admissible short form: с→c, р→p, о→o,
146/// к→k, so every abbreviation of it renders as Latin text. `пролёт` is the span
147/// of a bridge — *пролёт моста* — which is the structural sense the English
148/// name *span* carries, and `пр` is detectable because of the `п`.
149const RU: &[(TierName, Names)] = &[
150    (TierName::Deep, ns("глубь", "глуби", "гл")),
151    (TierName::Drift, ns("дрейф", "дрейфы", "др")),
152    (TierName::Span, ns("пролёт", "пролёты", "пр")),
153    (TierName::Sweep, ns("обход", "обходы", "обх")),
154    (TierName::Arc, ns("дуга", "дуги", "ду")),
155    (TierName::Beat, ns("бой", "бои", "бо")),
156    (TierName::Flicker, ns("мерцание", "мерцания", "мц")),
157    (TierName::Glint, ns("блик", "блики", "бл")),
158    (TierName::Spark, ns("искра", "искры", "ис")),
159    (TierName::Tick, ns("тик", "тики", "ти")),
160];
161
162/// Lowercase Cyrillic letters with no Latin twin in a typical terminal font.
163///
164/// The complement of `а с е о р х у к м н в т`. A short form must contain at
165/// least one of these, so that it is detectably Cyrillic rather than ambiguous
166/// with Latin text.
167pub const DETECTABLE: &str = "бгджзийлпфцчшщъыьэюя";
168
169/// The rule the short forms are held to, stated where it can be cited.
170pub const SHORT_FORMS_ARE_DETECTABLE: &str =
171    "Every locale short form contains at least one letter with no Latin homoglyph \
172     (ucal_core::locale::DETECTABLE), so it cannot be mistaken for Latin text; no \
173     two collide, and none is another's reversal.";
174
175/// The table for a locale.
176pub const fn table(locale: LocaleId) -> &'static [(TierName, Names)] {
177    match locale {
178        LocaleId::En => EN,
179        LocaleId::Ru => RU,
180    }
181}
182
183/// The names of a tier in a locale, if the tier is named at all.
184///
185/// Unnamed tiers return `None` and are addressed by index (D-20).
186pub fn names_of(locale: LocaleId, tier: Tier) -> Option<Names> {
187    let key = crate::tier::name_of(tier)?;
188    table(locale)
189        .iter()
190        .find(|(k, _)| *k == key)
191        .map(|(_, v)| *v)
192}
193
194/// A tier's display name in a locale, or its `T[k]` form if unnamed.
195///
196/// Always returns something printable, because Rule N guarantees the index
197/// notation is valid wherever a name is.
198#[cfg(feature = "alloc")]
199pub fn display(locale: LocaleId, tier: Tier) -> alloc::string::String {
200    use alloc::string::ToString;
201    match names_of(locale, tier) {
202        Some(n) => n.singular.to_string(),
203        None => tier.to_string(),
204    }
205}
206
207/// Resolve a tier from a locale name, a stable key, `T[k]`, or `5^e` (Rule N).
208///
209/// Singular and plural both resolve, and matching is case-insensitive for ASCII;
210/// a user who types `Deeps` means the same tier as one who types `deep`.
211pub fn resolve(locale: LocaleId, s: &str) -> Result<Tier> {
212    let t = s.trim();
213
214    // Index and exponent notation, accepted wherever a name is (Rule N).
215    if let Some(k) = t.strip_prefix('T') {
216        if let Ok(idx) = k.parse::<i8>() {
217            return Tier::new(idx);
218        }
219    }
220    if let Some(e) = t.strip_prefix("5^") {
221        if let Ok(exp) = e.parse::<u32>() {
222            return Tier::from_exponent(exp);
223        }
224    }
225
226    // Locale names, then the stable keys, so a key always works in any locale.
227    let lowered = ascii_lower(t);
228    for (key, names) in table(locale) {
229        if eq_fold(names.singular, &lowered) || eq_fold(names.plural, &lowered) {
230            return tier_of_name(*key);
231        }
232        // Rule N requires the index notation to be accepted wherever a name is.
233        // A short form is a name, so it resolves in the same places rather than
234        // being display-only — an abbreviation a reader can see and not type
235        // would be a worse kind of alias than none.
236        if let Some(short) = names.short {
237            if eq_fold(short, &lowered) {
238                return tier_of_name(*key);
239            }
240        }
241    }
242    for (k, key) in NAMED {
243        if eq_fold(key.key(), &lowered) {
244            return Tier::new(*k);
245        }
246    }
247    Err(TimeError::with_context(
248        Code::E0011,
249        "unknown tier name; try a locale name, a stable key, T<k>, or 5^e",
250    ))
251}
252
253fn tier_of_name(key: TierName) -> Result<Tier> {
254    NAMED
255        .iter()
256        .find(|(_, k)| *k == key)
257        .map(|(idx, _)| Tier::new(*idx))
258        .unwrap_or(Err(TimeError::new(Code::E0011)))
259}
260
261/// Case-insensitive comparison for ASCII, exact for everything else.
262///
263/// Deliberately not a full Unicode case fold: Russian tier names are compared as
264/// written. Case-folding Cyrillic correctly needs tables this crate has no reason
265/// to carry, and getting it half-right would be worse than not doing it.
266fn eq_fold(candidate: &str, lowered_input: &str) -> bool {
267    if candidate.is_ascii() {
268        candidate.eq_ignore_ascii_case(lowered_input)
269    } else {
270        candidate == lowered_input
271    }
272}
273
274#[cfg(feature = "alloc")]
275fn ascii_lower(s: &str) -> alloc::string::String {
276    s.chars()
277        .map(|c| if c.is_ascii() { c.to_ascii_lowercase() } else { c })
278        .collect()
279}
280
281#[cfg(not(feature = "alloc"))]
282fn ascii_lower(s: &str) -> &str {
283    s
284}
285
286/// Validate a locale table: every name distinct, every named tier covered.
287///
288/// Rule N makes a collision within an active table `UCAL-E0011`. Checked rather
289/// than assumed, because a collision would make a name ambiguous on input while
290/// still looking fine on output.
291pub fn validate(locale: LocaleId) -> Result<()> {
292    let t = table(locale);
293    // Every named tier must appear exactly once.
294    for (_, key) in NAMED {
295        let count = t.iter().filter(|(k, _)| k == key).count();
296        if count != 1 {
297            return Err(TimeError::with_context(
298                Code::E0010,
299                "locale table does not cover every named tier exactly once",
300            ));
301        }
302    }
303    // No two display names may collide, singular or plural.
304    for (i, (_, a)) in t.iter().enumerate() {
305        for (_, b) in t.iter().skip(i + 1) {
306            if a.singular == b.singular
307                || a.plural == b.plural
308                || a.singular == b.plural
309                || a.plural == b.singular
310            {
311                return Err(TimeError::with_context(
312                    Code::E0011,
313                    "duplicate name in the active locale table",
314                ));
315            }
316        }
317    }
318    Ok(())
319}
320
321#[cfg(test)]
322mod tests {
323    use super::*;
324
325    #[test]
326    fn every_shipped_locale_is_valid() {
327        for l in LocaleId::ALL {
328            validate(*l).unwrap_or_else(|e| panic!("locale {} is invalid: {e}", l.tag()));
329        }
330    }
331
332    #[test]
333    fn every_locale_covers_every_named_tier() {
334        // §13.5: the tier table and the locale table come from one source, so a
335        // tier cannot be named in one and missing from the other.
336        for l in LocaleId::ALL {
337            assert_eq!(table(*l).len(), NAMED.len(), "locale {}", l.tag());
338            for (k, _) in NAMED {
339                let tier = Tier::new(*k).unwrap();
340                assert!(
341                    names_of(*l, tier).is_some(),
342                    "locale {} is missing T{k}",
343                    l.tag()
344                );
345            }
346        }
347    }
348
349    #[test]
350    fn unnamed_tiers_stay_unnamed_in_every_locale() {
351        // D-20: tiers above T5 and below T-3 are addressable by index only.
352        for l in LocaleId::ALL {
353            for k in [6i8, 10, 32, -4, -8, -11] {
354                let tier = Tier::new(k).unwrap();
355                assert!(names_of(*l, tier).is_none(), "T{k} in {}", l.tag());
356                // ...but they always have a printable form.
357                assert_eq!(display(*l, tier), alloc::format!("T{k}"));
358            }
359        }
360    }
361
362    #[test]
363    fn names_resolve_in_both_locales() {
364        assert_eq!(resolve(LocaleId::En, "deep").unwrap(), Tier::DEEP);
365        assert_eq!(resolve(LocaleId::En, "deeps").unwrap(), Tier::DEEP);
366        assert_eq!(resolve(LocaleId::Ru, "глубь").unwrap(), Tier::DEEP);
367        assert_eq!(resolve(LocaleId::Ru, "глуби").unwrap(), Tier::DEEP);
368        assert_eq!(resolve(LocaleId::Ru, "бой").unwrap(), Tier::BEAT);
369        assert_eq!(resolve(LocaleId::Ru, "тик").unwrap(), Tier::TICK);
370    }
371
372    #[test]
373    fn the_stable_key_works_in_any_locale() {
374        // The key is the identity across locales, so `beat` resolves under `ru`
375        // even though the Russian display name is `бой`.
376        for l in LocaleId::ALL {
377            assert_eq!(resolve(*l, "beat").unwrap(), Tier::BEAT);
378            assert_eq!(resolve(*l, "deep").unwrap(), Tier::DEEP);
379        }
380    }
381
382    #[test]
383    fn index_and_exponent_notation_work_wherever_a_name_does() {
384        // Rule N states this explicitly.
385        for l in LocaleId::ALL {
386            assert_eq!(resolve(*l, "T0").unwrap(), Tier::BEAT);
387            assert_eq!(resolve(*l, "T-12").unwrap(), Tier::TICK);
388            assert_eq!(resolve(*l, "5^60").unwrap(), Tier::BEAT);
389            assert_eq!(resolve(*l, "5^220").unwrap(), Tier::new(32).unwrap());
390            // Including for tiers that have no name at all (D-20).
391            assert_eq!(resolve(*l, "T7").unwrap(), Tier::new(7).unwrap());
392            assert_eq!(resolve(*l, "5^95").unwrap(), Tier::new(7).unwrap());
393        }
394    }
395
396    #[test]
397    fn ascii_names_fold_case_but_cyrillic_is_taken_as_written() {
398        assert_eq!(resolve(LocaleId::En, "DEEP").unwrap(), Tier::DEEP);
399        assert_eq!(resolve(LocaleId::En, "Deeps").unwrap(), Tier::DEEP);
400        // Cyrillic is compared as written; folding it correctly needs tables this
401        // crate has no reason to carry.
402        assert!(resolve(LocaleId::Ru, "глубь").is_ok());
403        assert!(resolve(LocaleId::Ru, "ГЛУБЬ").is_err());
404    }
405
406    #[test]
407    fn unknown_names_are_e0011() {
408        for l in LocaleId::ALL {
409            assert_eq!(resolve(*l, "aeon").unwrap_err().code, Code::E0011);
410            assert_eq!(resolve(*l, "").unwrap_err().code, Code::E0011);
411        }
412        // An off-grid exponent is a tier error, not a naming one.
413        assert_eq!(resolve(LocaleId::En, "5^61").unwrap_err().code, Code::E0080);
414    }
415
416    #[test]
417    fn locale_tags_round_trip() {
418        for l in LocaleId::ALL {
419            assert_eq!(LocaleId::parse(l.tag()).unwrap(), *l);
420        }
421        assert_eq!(LocaleId::parse("xx").unwrap_err().code, Code::E0010);
422    }
423
424    #[test]
425    fn no_calendar_units_appear_in_the_ladder() {
426        // Appendix D: day, year and cycle belong to a body's calendar, not to the
427        // universal ladder. A name collision with one would invite exactly the
428        // conflation §8.3 exists to prevent.
429        for l in LocaleId::ALL {
430            for (_, names) in table(*l) {
431                for n in [names.singular, names.plural] {
432                    for forbidden in ["day", "year", "month", "week", "hour", "second"] {
433                        assert_ne!(n, forbidden, "locale {} names a calendar unit", l.tag());
434                    }
435                }
436            }
437        }
438    }
439
440    #[test]
441    fn names_avoid_the_content_appendix_d_rules_out() {
442        // "no mythological, religious, national, or numeric-prefix content".
443        for l in LocaleId::ALL {
444            for (_, names) in table(*l) {
445                let s = names.singular;
446                for prefix in ["kilo", "mega", "giga", "tera", "milli", "micro", "nano"] {
447                    assert!(!s.starts_with(prefix), "{s} carries a numeric prefix");
448                }
449                assert!(!s.is_empty());
450                // Short: every name is a single word.
451                assert!(!s.contains(' '), "{s} is not a single word");
452            }
453        }
454    }
455
456    // ------------------------------------------------------------- short forms
457
458    /// Every short form the tables ship, with its locale.
459    fn shorts() -> alloc::vec::Vec<(LocaleId, &'static str)> {
460        let mut v = alloc::vec::Vec::new();
461        for loc in LocaleId::ALL {
462            for (_, names) in table(*loc) {
463                if let Some(s) = names.short {
464                    v.push((*loc, s));
465                }
466            }
467        }
468        v
469    }
470
471    #[test]
472    fn every_short_form_is_detectably_not_latin() {
473        // The rule DETECTABLE exists for. A form built only from Cyrillic
474        // letters with Latin twins renders identically to Latin text, and this
475        // project designs that out rather than documenting around it — the UCID
476        // alphabet drops I, L and O for the same reason.
477        for (loc, s) in shorts() {
478            assert!(
479                s.chars().any(|c| DETECTABLE.contains(c)),
480                "{}: `{s}` is entirely Latin-homoglyphic",
481                loc.tag()
482            );
483        }
484    }
485
486    #[test]
487    fn no_two_short_forms_collide_or_reverse_each_other() {
488        for loc in LocaleId::ALL {
489            let v: alloc::vec::Vec<&str> = table(*loc)
490                .iter()
491                .filter_map(|(_, n)| n.short)
492                .collect();
493            for (i, a) in v.iter().enumerate() {
494                for (j, b) in v.iter().enumerate() {
495                    if i == j {
496                        continue;
497                    }
498                    assert_ne!(a, b, "{}: `{a}` is used twice", loc.tag());
499                    let rev: alloc::string::String = b.chars().rev().collect();
500                    assert_ne!(
501                        *a, rev,
502                        "{}: `{a}` and `{b}` are reversals of one another",
503                        loc.tag()
504                    );
505                }
506            }
507        }
508    }
509
510    #[test]
511    fn a_short_form_resolves_wherever_a_name_does() {
512        // Rule N: index notation is accepted wherever a name is. A short form is
513        // a name, so it resolves too — an abbreviation a reader can see and not
514        // type would be a worse alias than none at all.
515        for (_, names) in table(LocaleId::Ru) {
516            let Some(short) = names.short else { continue };
517            let by_short = resolve(LocaleId::Ru, short).expect("short form resolves");
518            let by_name = resolve(LocaleId::Ru, names.singular).expect("name resolves");
519            assert_eq!(by_short, by_name, "`{short}` and `{}` differ", names.singular);
520        }
521    }
522
523    #[test]
524    fn short_forms_do_not_leak_across_locales() {
525        // The scoping is the whole design: `пр` means T3 under `ru` and nothing
526        // at all under `en`, so a form cannot silently mean something else.
527        assert!(resolve(LocaleId::En, "пр").is_err());
528        assert!(resolve(LocaleId::En, "бо").is_err());
529        // And English ships none, because T[k] is already short and invariant.
530        assert!(table(LocaleId::En).iter().all(|(_, n)| n.short.is_none()));
531    }
532
533    #[test]
534    fn t3_is_the_span_of_a_bridge() {
535        // `срок` was the shipped name and had no admissible short form: с, р, о
536        // and к all have Latin twins. Pinned so that reverting the word without
537        // reading why is a test failure.
538        let names = table(LocaleId::Ru)
539            .iter()
540            .find(|(k, _)| *k == TierName::Span)
541            .map(|(_, n)| *n)
542            .expect("T3 is named in ru");
543        assert_eq!(names.singular, "пролёт");
544        assert_eq!(names.short, Some("пр"));
545        assert!(
546            !"срок".chars().any(|c| DETECTABLE.contains(c)),
547            "срок gained a detectable letter; the reason for the change moved"
548        );
549    }
550}