1use std::cell::RefCell;
20use std::collections::HashMap;
21
22day_reactive::tls_root! {
23 collate: crate::collate::TlsGroupSlots,
24 datetime: crate::datetime::TlsGroupSlots,
25 decimal: crate::decimal::TlsGroupSlots,
26 root: crate::TlsGroupSlots,
27}
28
29use day_reactive::Signal;
30use fluent_bundle::{FluentArgs, FluentBundle, FluentResource, FluentValue};
31use unic_langid::LanguageIdentifier;
32
33mod collate;
34mod datetime;
35mod decimal;
36mod intl;
37mod search;
38
39pub use collate::{compare, compare_in, sort_localized};
40pub use datetime::{DateFields, format_date, format_date_in};
41pub use decimal::{format_decimal, format_decimal_in, format_percent, format_percent_in};
42pub use search::{matches_search, matches_search_in};
43
44const CORE_CATALOG: &[(&str, &str)] = &[
47 ("en", include_str!("../catalog/en.ftl")),
48 ("fr", include_str!("../catalog/fr.ftl")),
49 ("es", include_str!("../catalog/es.ftl")),
50 ("de", include_str!("../catalog/de.ftl")),
51 ("ja", include_str!("../catalog/ja.ftl")),
52 ("zh", include_str!("../catalog/zh.ftl")),
53 ("ar", include_str!("../catalog/ar.ftl")),
54];
55
56include!(concat!(env!("OUT_DIR"), "/core_strings.rs"));
58
59pub struct Catalog {
63 pub name: &'static str,
64 pub default: &'static str,
65 pub locales: &'static [(&'static str, &'static str)],
66}
67
68struct ScopedBundles {
69 catalog: &'static Catalog,
70 bundles: HashMap<String, FluentBundle<FluentResource>>,
71}
72
73struct State {
74 scoped: HashMap<usize, ScopedBundles>,
75 app: HashMap<String, FluentBundle<FluentResource>>,
77 core: HashMap<String, FluentBundle<FluentResource>>,
79 default: String,
80 locale: Signal<String>,
81}
82
83day_reactive::tls_slots! {
84 root;
85 static STATE: RefCell<Option<State>> = const { RefCell::new(None) };
86
87 static LAUNCH_LOCALES: RefCell<Vec<String>> = const { RefCell::new(Vec::new()) };
92
93}
94
95fn build_bundles(locales: &[(&str, &str)]) -> HashMap<String, FluentBundle<FluentResource>> {
96 let mut bundles = HashMap::new();
97 for (name, src) in locales {
98 let langid: LanguageIdentifier = name.parse().unwrap_or_else(|_| "en".parse().unwrap());
99 let mut bundle = FluentBundle::new(vec![langid]);
100 intl::register(&mut bundle);
102 match FluentResource::try_new((*src).to_string()) {
105 Ok(res) => {
106 let _ = bundle.add_resource(res);
107 }
108 Err((res, errs)) => {
109 log::warn!("day-localization: {name}: {} syntax error(s)", errs.len());
110 let _ = bundle.add_resource(res);
111 }
112 }
113 bundles.insert((*name).to_string(), bundle);
114 }
115 bundles
116}
117
118fn ensure_state() {
121 STATE.with(|s| {
122 if s.borrow().is_some() {
123 return;
124 }
125 let initial = std::env::var("DAY_LOCALE")
126 .ok()
127 .map(normalize)
128 .unwrap_or_else(|| "en".to_string());
129 *s.borrow_mut() = Some(State {
130 app: HashMap::new(),
131 scoped: HashMap::new(),
132 core: build_bundles(CORE_CATALOG),
133 default: "en".to_string(),
134 locale: Signal::global(initial),
140 });
141 });
142}
143
144pub fn set_launch_locale(locale: &str) {
147 set_launch_locales(&[locale.to_string()]);
148}
149
150pub fn add_launch_locales(locales: &[String]) {
156 LAUNCH_LOCALES.with(|l| {
157 let mut l = l.borrow_mut();
158 for loc in locales {
159 if !l.contains(loc) {
160 l.push(loc.clone());
161 }
162 }
163 });
164}
165
166pub fn set_launch_locales(locales: &[String]) {
173 LAUNCH_LOCALES.with(|l| *l.borrow_mut() = locales.to_vec());
174}
175
176pub fn install(default: &str, locales: &[(&str, &str)]) {
182 ensure_state();
183 STATE.with(|s| {
184 let mut st = s.borrow_mut();
185 let st = st.as_mut().unwrap();
186 st.app = build_bundles(locales);
187 st.default = default.to_string();
188 let serves = |l: &str| {
196 let lang = l.split('-').next().unwrap_or(l);
197 let keys = [l, base_locale(l), lang];
198 let catalogs = if st.app.is_empty() { &st.core } else { &st.app };
199 keys.iter().any(|k| catalogs.contains_key(*k)) || is_pseudo(l)
200 };
201 let candidates = std::env::var("DAY_LOCALE")
202 .ok()
203 .map(|l| vec![l])
204 .unwrap_or_else(|| LAUNCH_LOCALES.with(|l| l.borrow().clone()));
205 let initial = candidates
206 .into_iter()
207 .map(normalize)
208 .find(|l| serves(l))
209 .unwrap_or_else(|| default.to_string());
210 st.locale.set(initial);
211 });
212}
213
214fn normalize(l: String) -> String {
215 l.replace('_', "-")
217}
218
219fn base_locale(l: &str) -> &str {
223 l.split("-u-").next().unwrap_or(l)
224}
225
226pub fn locale() -> Signal<String> {
228 ensure_state();
229 STATE.with(|s| s.borrow().as_ref().unwrap().locale)
230}
231
232pub fn set_locale(l: &str) {
234 locale().set(normalize(l.to_string()));
235}
236
237#[derive(Clone)]
238pub enum FArg {
239 Str(String),
240 Num(f64),
241 SigStr(Signal<String>),
242 SigI64(Signal<i64>),
243 SigF64(Signal<f64>),
244}
245
246impl FArg {
247 fn resolve(&self) -> FluentValue<'static> {
249 match self {
250 FArg::Str(s) => FluentValue::from(s.clone()),
251 FArg::Num(n) => FluentValue::from(*n),
252 FArg::SigStr(s) => FluentValue::from(s.get()),
253 FArg::SigI64(s) => FluentValue::from(s.get()),
254 FArg::SigF64(s) => FluentValue::from(s.get()),
255 }
256 }
257}
258
259pub trait IntoFArg<M> {
261 fn into_farg(self) -> FArg;
262}
263pub struct ValM;
264pub struct SigM;
265impl IntoFArg<ValM> for &str {
266 fn into_farg(self) -> FArg {
267 FArg::Str(self.to_owned())
268 }
269}
270impl IntoFArg<ValM> for String {
271 fn into_farg(self) -> FArg {
272 FArg::Str(self)
273 }
274}
275impl IntoFArg<ValM> for i64 {
276 fn into_farg(self) -> FArg {
277 FArg::Num(self as f64)
278 }
279}
280impl IntoFArg<ValM> for f64 {
281 fn into_farg(self) -> FArg {
282 FArg::Num(self)
283 }
284}
285impl IntoFArg<SigM> for Signal<String> {
286 fn into_farg(self) -> FArg {
287 FArg::SigStr(self)
288 }
289}
290impl IntoFArg<SigM> for Signal<i64> {
291 fn into_farg(self) -> FArg {
292 FArg::SigI64(self)
293 }
294}
295impl IntoFArg<SigM> for Signal<f64> {
296 fn into_farg(self) -> FArg {
297 FArg::SigF64(self)
298 }
299}
300
301pub trait IntoNumberFArg<M>: IntoFArg<M> {}
307impl IntoNumberFArg<ValM> for i64 {}
308impl IntoNumberFArg<ValM> for f64 {}
309impl IntoNumberFArg<SigM> for Signal<i64> {}
310impl IntoNumberFArg<SigM> for Signal<f64> {}
311
312fn message_from(
314 map: &HashMap<String, FluentBundle<FluentResource>>,
315 locale_name: &str,
316 key: &str,
317 args: &FluentArgs,
318) -> Option<String> {
319 let bundle = map
320 .get(locale_name)
321 .or_else(|| map.get(base_locale(locale_name)))
322 .or_else(|| {
323 let lang = locale_name.split('-').next().unwrap_or(locale_name);
324 map.get(lang)
325 })
326 .or_else(|| {
327 let base = base_locale(locale_name);
329 let alias = match base {
330 "zh-Hans" | "zh-Hans-CN" | "zh-SG" => "zh-CN",
331 "zh-Hant" | "zh-Hant-TW" => "zh-TW",
332 _ => return None,
333 };
334 map.get(alias)
335 })?;
336 let (msg_key, attr) = match key.split_once('.') {
341 Some((m, a)) => (m, Some(a)),
342 None => (key, None),
343 };
344 let msg = bundle.get_message(msg_key)?;
345 let pattern = match attr {
346 Some(a) => msg.get_attribute(a)?.value(),
347 None => msg.value()?,
348 };
349 let mut errs = Vec::new();
350 Some(
351 bundle
352 .format_pattern(pattern, Some(args), &mut errs)
353 .into_owned(),
354 )
355}
356
357pub fn register_catalog(catalog: &'static Catalog) {
360 ensure_state();
361 STATE.with(|s| {
362 s.borrow_mut()
363 .as_mut()
364 .unwrap()
365 .scoped
366 .entry(catalog as *const Catalog as usize)
367 .or_insert_with(|| ScopedBundles {
368 catalog,
369 bundles: build_bundles(catalog.locales),
370 });
371 });
372}
373
374pub fn format_catalog(
377 catalog: &'static Catalog,
378 locale_name: &str,
379 key: &str,
380 args: &[(String, FArg)],
381) -> String {
382 register_catalog(catalog);
383 STATE.with(|s| {
384 let st = s.borrow();
385 let scope = &st.as_ref().unwrap().scoped[&(catalog as *const Catalog as usize)];
386 resolve_scoped(scope, locale_name, key, args)
387 })
388}
389
390fn resolve_scoped(
391 scope: &ScopedBundles,
392 locale_name: &str,
393 key: &str,
394 args: &[(String, FArg)],
395) -> String {
396 let mut fargs = FluentArgs::new();
397 for (k, v) in args {
398 fargs.set(k.clone(), v.resolve());
399 }
400 let base = locale_name.strip_suffix(PSEUDO_SUFFIX);
401 let lookup = base.unwrap_or(locale_name);
402 match message_from(&scope.bundles, lookup, key, &fargs)
403 .or_else(|| message_from(&scope.bundles, scope.catalog.default, key, &fargs))
404 {
405 Some(out) if base.is_some() => pseudolocalize(&out),
406 Some(out) => out,
407 None => format!("⟨{}::{key}⟩", scope.catalog.name),
408 }
409}
410
411pub fn format_in(locale_name: &str, key: &str, args: &[(String, FArg)]) -> String {
416 ensure_state();
417 STATE.with(|s| {
418 let st = s.borrow();
419 let st = st.as_ref().unwrap();
420 if let Some((name, local_key)) = key.rsplit_once("::") {
422 let mut matches = st.scoped.values().filter(|s| s.catalog.name == name);
423 if let Some(scope) = matches.next()
424 && matches.next().is_none()
425 {
426 return resolve_scoped(scope, locale_name, local_key, args);
427 }
428 return format!("⟨{key}⟩");
429 }
430 let mut fargs = FluentArgs::new();
431 for (k, v) in args {
432 fargs.set(k.clone(), v.resolve());
433 }
434 let base = locale_name.strip_suffix(PSEUDO_SUFFIX);
435 let pseudo = base.is_some();
436 let lookup = base.unwrap_or(locale_name);
437 let out = message_from(&st.app, lookup, key, &fargs)
438 .or_else(|| message_from(&st.app, &st.default, key, &fargs))
439 .or_else(|| message_from(&st.core, lookup, key, &fargs))
440 .or_else(|| message_from(&st.core, "en", key, &fargs));
441 match out {
442 Some(out) => {
443 if pseudo {
444 pseudolocalize(&out)
445 } else {
446 out
447 }
448 }
449 None => format!("⟨{key}⟩"),
450 }
451 })
452}
453
454pub fn t(key: &str) -> String {
458 format_in(&locale().get(), key, &[])
459}
460
461const PSEUDO_SUFFIX: &str = "-XA";
464
465fn is_pseudo(l: &str) -> bool {
467 l.ends_with(PSEUDO_SUFFIX)
468}
469
470fn pseudolocalize(s: &str) -> String {
472 let mut out: String = s
473 .chars()
474 .map(|c| match c {
475 'a' => 'á',
476 'e' => 'é',
477 'i' => 'í',
478 'o' => 'ó',
479 'u' => 'ú',
480 'y' => 'ý',
481 'A' => 'Á',
482 'E' => 'É',
483 'I' => 'Í',
484 'O' => 'Ó',
485 'U' => 'Ú',
486 'Y' => 'Ý',
487 other => other,
488 })
489 .collect();
490 out.push_str(" ・ロング");
491 out
492}
493
494pub fn strip_isolates(s: &str) -> String {
496 s.chars()
497 .filter(|c| !matches!(c, '\u{2068}' | '\u{2069}'))
498 .collect()
499}
500
501#[cfg(test)]
502mod launch_locale_tests {
503 use super::*;
504
505 #[test]
509 fn install_picks_the_first_preference_it_can_serve() {
510 set_launch_locales(&[
511 "de-DE".to_string(), "fr-CA".to_string(), "en".to_string(),
514 ]);
515 install(
516 "en",
517 &[("en", "hello = Hello\n"), ("fr", "hello = Bonjour\n")],
518 );
519 assert_eq!(locale().get(), "fr-CA");
520 assert_eq!(
521 t("hello"),
522 "Bonjour",
523 "and it resolves through the language half"
524 );
525 }
526
527 #[test]
529 fn an_explicit_override_outranks_the_device() {
530 set_launch_locales(&[]);
531 set_launch_locale("fr");
532 add_launch_locales(&["en-US".to_string()]);
533 install(
534 "en",
535 &[("en", "hello = Hello\n"), ("fr", "hello = Bonjour\n")],
536 );
537 assert_eq!(t("hello"), "Bonjour");
538 }
539}
540
541#[cfg(test)]
542mod tests {
543 use super::*;
544
545 #[test]
546 fn core_catalog_resolves_without_install_and_across_locales() {
547 assert_eq!(
549 strip_isolates(&format_in("en", key::day_cancel, &[])),
550 "Cancel"
551 );
552 assert_eq!(
553 strip_isolates(&format_in("fr", key::day_cancel, &[])),
554 "Annuler"
555 );
556 assert_eq!(
557 strip_isolates(&format_in("de", key::day_cancel, &[])),
558 "Abbrechen"
559 );
560 assert_eq!(strip_isolates(&format_in("ja", key::day_ok, &[])), "OK");
561 assert_eq!(strip_isolates(&format_in("xx", key::day_copy, &[])), "Copy");
563 assert_eq!(format_in("en", "nope", &[]), "⟨nope⟩");
565 }
566
567 #[test]
568 fn shortcut_key_attributes_resolve_and_fall_back_across_locales() {
569 install(
570 "en",
571 &[
572 ("en", "menu_group = Group\n .key = g\n"),
573 ("fr", "menu_group = Grouper\n"),
576 ("de", "menu_group = Gruppieren\n .key = r\n"),
578 ],
579 );
580 assert_eq!(strip_isolates(&format_in("en", "menu_group.key", &[])), "g");
581 assert_eq!(
582 strip_isolates(&format_in("fr", "menu_group.key", &[])),
583 "g",
584 "an omitted attribute falls back to the default locale's"
585 );
586 assert_eq!(
587 strip_isolates(&format_in("de", "menu_group.key", &[])),
588 "r",
589 "a locale may deliberately override"
590 );
591 assert_eq!(
592 strip_isolates(&format_in("fr", "menu_group", &[])),
593 "Grouper",
594 "the message value is untouched"
595 );
596 assert_eq!(format_in("en", "menu_group.nope", &[]), "⟨menu_group.nope⟩");
597 install("en", &[]); }
599
600 #[test]
601 fn menu_role_labels_and_interpolated_app_commands_localize() {
602 let app = |lang, key, val: &str| {
603 let out = format_in(lang, key, &[("app".to_string(), FArg::Str("Day".into()))]);
604 assert_eq!(strip_isolates(&out), val, "{lang}/{key}");
605 };
606 assert_eq!(
608 strip_isolates(&format_in("fr", key::day_cut, &[])),
609 "Couper"
610 );
611 assert_eq!(
612 strip_isolates(&format_in("de", key::day_paste, &[])),
613 "Einfügen"
614 );
615 assert_eq!(
616 strip_isolates(&format_in("es", key::day_select_all, &[])),
617 "Seleccionar todo"
618 );
619 app("en", key::day_quit_app, "Quit Day");
621 app("fr", key::day_quit_app, "Quitter Day");
622 app("de", key::day_quit_app, "Day beenden");
623 app("ja", key::day_quit_app, "Dayを終了");
624 app("fr", key::day_about_app, "À propos de Day");
625 }
626
627 #[test]
628 fn app_bundle_overrides_core_but_core_is_the_fallback() {
629 install("en", &[("en", "day_cancel = Dismiss\ngreeting = Hi")]);
630 assert_eq!(strip_isolates(&format_in("en", "greeting", &[])), "Hi");
632 assert_eq!(
634 strip_isolates(&format_in("en", key::day_cancel, &[])),
635 "Dismiss"
636 );
637 assert_eq!(strip_isolates(&format_in("en", key::day_ok, &[])), "OK");
639 }
640}
641
642#[cfg(test)]
643mod scoped_tests {
644 use super::*;
645 static A: Catalog = Catalog {
646 name: "game_a",
647 default: "en",
648 locales: &[
649 (
650 "en",
651 "game_title = Alpha\nonly_a = Private\ngreeting = Hello { $name }\nmenu = Menu\n .key = a\n",
652 ),
653 (
654 "fr",
655 "game_title = Alphabète\ngreeting = Bonjour { $name }\n",
656 ),
657 ("zh-CN", "game_title = 阿尔法\n"),
658 ],
659 };
660 static B: Catalog = Catalog {
661 name: "game_b",
662 default: "en",
663 locales: &[("en", "game_title = Beta\n")],
664 };
665
666 #[test]
667 fn private_keys_do_not_leak_or_depend_on_registration_order() {
668 install(
669 "en",
670 &[("en", "game_title = Application\nonly_a = App impostor\n")],
671 );
672 for catalog in [&B, &A, &B, &A] {
673 let expected = if std::ptr::eq(catalog, &A) {
674 "Alpha"
675 } else {
676 "Beta"
677 };
678 assert_eq!(format_catalog(catalog, "en", "game_title", &[]), expected);
679 }
680 assert_eq!(format_in("en", "game_title", &[]), "Application");
681 assert_eq!(format_catalog(&B, "en", "only_a", &[]), "⟨game_b::only_a⟩");
682 assert_eq!(
683 format_catalog(&B, "en", key::day_ok, &[]),
684 "⟨game_b::day_ok⟩"
685 );
686 install("en", &[]);
687 assert_eq!(format_catalog(&A, "en", "game_title", &[]), "Alpha");
688 }
689
690 #[test]
691 fn scope_fallback_attributes_pseudo_and_regional_locales() {
692 assert_eq!(
693 format_catalog(&A, "fr-CA-u-nu-latn", "game_title", &[]),
694 "Alphabète"
695 );
696 assert_eq!(format_catalog(&A, "fr", "only_a", &[]), "Private");
697 assert_eq!(format_catalog(&A, "xx", "game_title", &[]), "Alpha");
698 assert_eq!(format_catalog(&A, "fr", "menu.key", &[]), "a");
699 assert_eq!(
700 format_catalog(&A, "fr-XA", "game_title", &[]),
701 pseudolocalize("Alphabète")
702 );
703 for locale in ["zh-CN", "zh-Hans", "zh-Hans-CN", "zh-SG"] {
704 assert_eq!(format_catalog(&A, locale, "game_title", &[]), "阿尔法");
705 }
706 }
707
708 #[test]
709 fn qualified_scripts_are_explicit_and_ambiguous_names_fail_closed() {
710 register_catalog(&A);
711 register_catalog(&B);
712 assert_eq!(format_in("en", "game_a::game_title", &[]), "Alpha");
713 assert_eq!(format_in("en", "game_b::game_title", &[]), "Beta");
714 assert_eq!(
715 format_in("en", "absent::game_title", &[]),
716 "⟨absent::game_title⟩"
717 );
718 static DUPLICATE: Catalog = Catalog {
719 name: "game_a",
720 default: "en",
721 locales: &[("en", "game_title = Other version\n")],
722 };
723 register_catalog(&DUPLICATE);
724 assert_eq!(
725 format_in("en", "game_a::game_title", &[]),
726 "⟨game_a::game_title⟩"
727 );
728 assert_eq!(format_catalog(&A, "en", "game_title", &[]), "Alpha");
729 assert_eq!(
730 format_catalog(&DUPLICATE, "en", "game_title", &[]),
731 "Other version"
732 );
733 }
734
735 #[test]
736 fn shared_locale_and_signal_arguments_react_without_reinstalling_catalogs() {
737 use std::rc::Rc;
738 set_locale("en");
739 let name = Signal::new("Ada".to_string());
740 let observed = Rc::new(RefCell::new(Vec::new()));
741 let values = observed.clone();
742 let scope = day_reactive::Scope::child();
743 scope.enter(|| {
744 day_reactive::Effect::new(move || {
745 values.borrow_mut().push(strip_isolates(&format_catalog(
746 &A,
747 &locale().get(),
748 "greeting",
749 &[("name".into(), FArg::SigStr(name))],
750 )));
751 });
752 });
753 day_reactive::batch(|| set_locale("fr"));
754 day_reactive::batch(|| name.set("Grace".to_string()));
755 assert_eq!(
756 *observed.borrow(),
757 ["Hello Ada", "Bonjour Ada", "Bonjour Grace"]
758 );
759 scope.dispose();
760 }
761}