1use crate::error::{Code, Result, TimeError};
28use crate::tier::{Tier, TierName, NAMED};
29
30#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Debug, Default)]
32#[non_exhaustive]
33pub enum LocaleId {
34 #[default]
36 En,
37 Ru,
39}
40
41impl LocaleId {
42 pub const fn tag(self) -> &'static str {
44 match self {
45 LocaleId::En => "en",
46 LocaleId::Ru => "ru",
47 }
48 }
49
50 pub const ALL: &'static [LocaleId] = &[LocaleId::En, LocaleId::Ru];
52
53 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#[derive(Clone, Copy, PartialEq, Eq, Debug)]
72#[non_exhaustive]
73pub struct Names {
74 pub singular: &'static str,
76 pub plural: &'static str,
78 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
108const 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
123const 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
162pub const DETECTABLE: &str = "бгджзийлпфцчшщъыьэюя";
168
169pub 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
175pub const fn table(locale: LocaleId) -> &'static [(TierName, Names)] {
177 match locale {
178 LocaleId::En => EN,
179 LocaleId::Ru => RU,
180 }
181}
182
183pub 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#[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
207pub fn resolve(locale: LocaleId, s: &str) -> Result<Tier> {
212 let t = s.trim();
213
214 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 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 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
261fn 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
286pub fn validate(locale: LocaleId) -> Result<()> {
292 let t = table(locale);
293 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 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 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 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 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 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 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 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 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 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 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 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 assert!(!s.contains(' '), "{s} is not a single word");
452 }
453 }
454 }
455
456 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 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 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 assert!(resolve(LocaleId::En, "пр").is_err());
528 assert!(resolve(LocaleId::En, "бо").is_err());
529 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 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}