Skip to main content

day_localization/
lib.rs

1// Copyright © The Daybrite Project
2// SPDX-License-Identifier: MPL-2.0
3
4//! day-localization: the core localization engine (DESIGN.md §12), low enough in the crate graph that even
5//! the central crates (day-pieces' dialogs, menu-role labels) can localize their own UI strings.
6//!
7//! Two tiers of global Fluent bundles:
8//! - **core**: a built-in catalog (`catalog/*.ftl`) of the standard strings the framework itself
9//!   emits (dialog OK/Cancel, standard menu commands), shipped in several languages. Always present.
10//! - **app**: the locales an app registers with [`install`]. These take precedence over core, so an
11//!   app can override any `day_*` string, and core is the fallback for keys the app didn't define.
12//!
13//! Private [`Catalog`] namespaces live alongside these tiers. They share the locale signal,
14//! but never merge their messages into app/core or each other.
15//!
16//! The current locale is a [`Signal`], so every `format`/binding re-runs on a locale switch. The
17//! piece-facing reactive `tr()` wrapper lives one layer up in `day-localization-ui` (which re-exports this).
18
19use 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
44/// The built-in core catalog: standard UI strings the framework needs, per language. Apps override
45/// individual keys by defining them in their own catalog (see [`install`]).
46const 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
56// `key::<name>` and the `__core_strings!` accessors, generated from catalog/en.ftl by build.rs.
57include!(concat!(env!("OUT_DIR"), "/core_strings.rs"));
58
59/// An embedded, private Fluent namespace. Generated by `day_build::generate_locales`.
60/// Catalog identity is its static address, so identical keys in different crates never collide.
61/// `name` is only the qualified lookup name used by scripting (`crate::file::key`).
62pub 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-registered bundles (from `install`), keyed by locale; take precedence over `core`.
76    app: HashMap<String, FluentBundle<FluentResource>>,
77    /// Built-in core catalog bundles, keyed by locale; the fallback for `day_*` keys.
78    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    /// The host's ordered locale preference, set by the platform entry before `install` runs:
88    /// the entry point for hosts with no process environment (web-dom seeds it from the page's
89    /// `?locale=`, native backends from the OS). The `DAY_LOCALE` environment variable, where one
90    /// exists, still wins.
91    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        // icu4x-backed NUMBER()/DATETIME() + the bundle-wide number formatter (src/intl.rs).
101        intl::register(&mut bundle);
102        // Overriding the same key across resources (app > core when merged) is expected; Fluent
103        // errors on duplicate adds, so we keep app and core in separate bundles and pick at lookup.
104        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
118/// Lazily create the state with the core catalog loaded, so core strings (and `format_in`) work even
119/// before an app calls [`install`]. The initial locale honors `DAY_LOCALE`.
120fn 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            // ROOT-scoped: this is a process-global signal, but `ensure_state` runs on the
135            // First touch of any l10n API, which can be inside a transient scope (a cover's
136            // presented content calling `t()` was the first observed case; the signal then
137            // died with that cover and every later read panicked). Globals must never
138            // inherit a caller's scope.
139            locale: Signal::global(initial),
140        });
141    });
142}
143
144/// Record the host's launch locale before [`install`] runs. Platform glue only; apps pick
145/// locales with `set_locale`. No-op once `install` has resolved the initial locale.
146pub fn set_launch_locale(locale: &str) {
147    set_launch_locales(&[locale.to_string()]);
148}
149
150/// Append the host's ordered preference after anything already recorded.
151///
152/// The two sources arrive in that order and both matter: a host override (web-dom's `?locale=`,
153/// set by the page glue) is a deliberate choice and stays first; the OS preference the backend
154/// reports is the fallback behind it. Duplicates are dropped, so a repeat call cannot reorder.
155pub 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
166/// Record the host's ordered locale preference (`["fr-CA", "fr", "en"]`) before [`install`] runs.
167///
168/// An OS answers with a list, not a locale, and the app's catalogs are not registered yet, so the
169/// list is kept whole and [`install`] picks the first entry it can serve. Taking only the
170/// first would drop a user whose phone reads "Canadian French, then French" for an app that ships
171/// `fr` but not `fr-CA`.
172pub fn set_launch_locales(locales: &[String]) {
173    LAUNCH_LOCALES.with(|l| *l.borrow_mut() = locales.to_vec());
174}
175
176/// Register an app's locales from `.ftl` sources and set the current locale from (1) the `DAY_LOCALE`
177/// launch override, (2) the platform's [`set_launch_locale`] hint, (3) `default`. Call once, before
178/// building the root piece. The built-in core catalog is preserved (and remains the fallback); the
179/// app's strings take precedence over it, and re-registering reuses the existing locale [`Signal`]
180/// so bindings created earlier keep working.
181pub 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        // Accept the first candidate that resolves (exactly, sans `-u-…` extension, or by
189        // language half, `fr-FR` → `fr`), mirroring `message_from`'s lookup, so a regional or
190        // extension-carrying candidate isn't silently dropped to the default.
191        // The app's catalogs decide the app's language. The core catalog ships more languages
192        // than most apps translate into, and letting it answer would hand a German phone an app
193        // whose own text is English and whose framework strings are German, a mix no user asked
194        // for. Core decides only when the app registered nothing at all.
195        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    // fr_FR / fr-FR → try exact, else the language half.
216    l.replace('_', "-")
217}
218
219/// The locale string without any `-u-…` Unicode extension: `"zh-u-co-stroke"` → `"zh"`. Bundle
220/// lookup uses the base (translations don't vary by collation/numbering preferences); the
221/// collator ([`compare_in`]) keeps the full string so extensions still select tailorings.
222fn base_locale(l: &str) -> &str {
223    l.split("-u-").next().unwrap_or(l)
224}
225
226/// The current locale (a tracked read inside bindings).
227pub fn locale() -> Signal<String> {
228    ensure_state();
229    STATE.with(|s| s.borrow().as_ref().unwrap().locale)
230}
231
232/// Switch the locale at runtime; every `tr`/binding re-runs.
233pub 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    /// Tracked resolution (signal args subscribe the binding).
248    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
259/// Disjoint-marker conversion for `.arg` values (the same E0119 dodge as `IntoText`).
260pub 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
301/// Marker for [`IntoFArg`] values that carry a **number**, required for a Fluent variable used as a
302/// plural / `select` selector, where CLDR plural rules select on a number (so a string can't be
303/// passed there by mistake). Implemented for the numeric `IntoFArg` types only (`i64`, `f64`, and
304/// their `Signal`s); the generated `res::str::<key>(…)` functions (§18.5) type such parameters as
305/// `impl IntoNumberFArg` instead of `impl IntoFArg`.
306pub 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
312/// Resolve `key` in the given locale from one bundle map (exact locale, then the language half).
313fn 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            // Apple uses script tags while stores commonly use region tags.
328            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    // `message.attribute` reaches a Fluent attribute (a message id itself can never contain
337    // a dot, so the split is unambiguous). This is how localized keyboard-shortcut keys resolve
338    // (`menu_group.key`, docs/localization.md): the attribute lives beside its command's
339    // label, and a locale that omits it falls through this chain to the default's.
340    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
357/// Make a catalog available for qualified scripting lookups. Generated accessors call this
358/// automatically. Registration never changes the app locale or its default catalog.
359pub 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
374/// Format within one private namespace; missing keys fall back only to this catalog's default.
375/// App/core catalogs cannot accidentally supply a similarly named key from another component.
376pub 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
411/// Resolve `key`: app bundle for the locale, then app-default, then the built-in core catalog for the
412/// locale, then core English. A pseudo-locale (`en-XA`, `fr-XA`: any locale with the `-XA` suffix)
413/// resolves against the locale under the suffix and accents the result. Signal args are tracked
414/// reads.
415pub 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        // Qualified keys are explicit: never search unrelated catalogs by a bare key.
421        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
454/// Resolve `key` in the current locale (no args): the one-shot form the framework's own strings use
455/// (dialog buttons, menu-role labels), which are resolved once at present/build time. The key is
456/// always a generated name, `t(key::day_cancel)`, never a string literal.
457pub fn t(key: &str) -> String {
458    format_in(&locale().get(), key, &[])
459}
460
461/// The suffix that turns any locale into its pseudo-locale (`fr` → `fr-XA`): the strings of the
462/// locale underneath, accented and expanded (docs/localization.md).
463const PSEUDO_SUFFIX: &str = "-XA";
464
465/// Whether `l` is a pseudo-locale.
466fn is_pseudo(l: &str) -> bool {
467    l.ends_with(PSEUDO_SUFFIX)
468}
469
470/// Accent + expansion transform for layout-stress testing (the `-XA` pseudo-locales).
471fn 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
494/// Strip Fluent's FSI/PDI isolation marks (dayscript text comparison, §14.3).
495pub 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    /// The device answers with a list and its first entry is often regional. Both facts have to
506    /// survive: `fr-CA` must reach an app that ships only `fr`, and a language the app does not
507    /// have at all must fall through to the next preference rather than to the default.
508    #[test]
509    fn install_picks_the_first_preference_it_can_serve() {
510        set_launch_locales(&[
511            "de-DE".to_string(), // not shipped
512            "fr-CA".to_string(), // shipped as `fr`
513            "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    /// An explicit host override stays in front of whatever the OS reports.
528    #[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        // Works before any `install`; the core catalog self-initializes.
548        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        // Unknown locale → English core fallback.
562        assert_eq!(strip_isolates(&format_in("xx", key::day_copy, &[])), "Copy");
563        // Unknown key → visible marker, never a panic.
564        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 omits the attribute, so the shortcut stays the default locale's, which is
574                // the industry posture (letters stay stable across languages)…
575                ("fr", "menu_group = Grouper\n"),
576                // …while a locale that needs to differ declares it.
577                ("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", &[]); // leave the thread's shared state clean for other tests
598    }
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        // Standard menu-command labels (used by day-pieces `lower_menu` for role items).
607        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        // Interpolated App-menu commands with per-language word order (AppKit About/Quit).
620        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        // App key wins for its own strings.
631        assert_eq!(strip_isolates(&format_in("en", "greeting", &[])), "Hi");
632        // App override of a core key wins.
633        assert_eq!(
634            strip_isolates(&format_in("en", key::day_cancel, &[])),
635            "Dismiss"
636        );
637        // A core key the app didn't define still resolves from the built-in catalog.
638        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}