use std::cell::RefCell;
use std::collections::HashMap;
day_reactive::tls_root! {
collate: crate::collate::TlsGroupSlots,
datetime: crate::datetime::TlsGroupSlots,
decimal: crate::decimal::TlsGroupSlots,
root: crate::TlsGroupSlots,
}
use day_reactive::Signal;
use fluent_bundle::{FluentArgs, FluentBundle, FluentResource, FluentValue};
use unic_langid::LanguageIdentifier;
mod collate;
mod datetime;
mod decimal;
mod intl;
mod search;
pub use collate::{compare, compare_in, sort_localized};
pub use datetime::{DateFields, format_date, format_date_in};
pub use decimal::{format_decimal, format_decimal_in, format_percent, format_percent_in};
pub use search::{matches_search, matches_search_in};
const CORE_CATALOG: &[(&str, &str)] = &[
("en", include_str!("../catalog/en.ftl")),
("fr", include_str!("../catalog/fr.ftl")),
("es", include_str!("../catalog/es.ftl")),
("de", include_str!("../catalog/de.ftl")),
("ja", include_str!("../catalog/ja.ftl")),
("zh", include_str!("../catalog/zh.ftl")),
("ar", include_str!("../catalog/ar.ftl")),
];
include!(concat!(env!("OUT_DIR"), "/core_strings.rs"));
pub struct Catalog {
pub name: &'static str,
pub default: &'static str,
pub locales: &'static [(&'static str, &'static str)],
}
struct ScopedBundles {
catalog: &'static Catalog,
bundles: HashMap<String, FluentBundle<FluentResource>>,
}
struct State {
scoped: HashMap<usize, ScopedBundles>,
app: HashMap<String, FluentBundle<FluentResource>>,
core: HashMap<String, FluentBundle<FluentResource>>,
default: String,
locale: Signal<String>,
}
day_reactive::tls_slots! {
root;
static STATE: RefCell<Option<State>> = const { RefCell::new(None) };
static LAUNCH_LOCALES: RefCell<Vec<String>> = const { RefCell::new(Vec::new()) };
}
fn build_bundles(locales: &[(&str, &str)]) -> HashMap<String, FluentBundle<FluentResource>> {
let mut bundles = HashMap::new();
for (name, src) in locales {
let langid: LanguageIdentifier = name.parse().unwrap_or_else(|_| "en".parse().unwrap());
let mut bundle = FluentBundle::new(vec![langid]);
intl::register(&mut bundle);
match FluentResource::try_new((*src).to_string()) {
Ok(res) => {
let _ = bundle.add_resource(res);
}
Err((res, errs)) => {
log::warn!("day-localization: {name}: {} syntax error(s)", errs.len());
let _ = bundle.add_resource(res);
}
}
bundles.insert((*name).to_string(), bundle);
}
bundles
}
fn ensure_state() {
STATE.with(|s| {
if s.borrow().is_some() {
return;
}
let initial = std::env::var("DAY_LOCALE")
.ok()
.map(normalize)
.unwrap_or_else(|| "en".to_string());
*s.borrow_mut() = Some(State {
app: HashMap::new(),
scoped: HashMap::new(),
core: build_bundles(CORE_CATALOG),
default: "en".to_string(),
locale: Signal::global(initial),
});
});
}
pub fn set_launch_locale(locale: &str) {
set_launch_locales(&[locale.to_string()]);
}
pub fn add_launch_locales(locales: &[String]) {
LAUNCH_LOCALES.with(|l| {
let mut l = l.borrow_mut();
for loc in locales {
if !l.contains(loc) {
l.push(loc.clone());
}
}
});
}
pub fn set_launch_locales(locales: &[String]) {
LAUNCH_LOCALES.with(|l| *l.borrow_mut() = locales.to_vec());
}
pub fn install(default: &str, locales: &[(&str, &str)]) {
ensure_state();
STATE.with(|s| {
let mut st = s.borrow_mut();
let st = st.as_mut().unwrap();
st.app = build_bundles(locales);
st.default = default.to_string();
let serves = |l: &str| {
let lang = l.split('-').next().unwrap_or(l);
let keys = [l, base_locale(l), lang];
let catalogs = if st.app.is_empty() { &st.core } else { &st.app };
keys.iter().any(|k| catalogs.contains_key(*k)) || is_pseudo(l)
};
let candidates = std::env::var("DAY_LOCALE")
.ok()
.map(|l| vec![l])
.unwrap_or_else(|| LAUNCH_LOCALES.with(|l| l.borrow().clone()));
let initial = candidates
.into_iter()
.map(normalize)
.find(|l| serves(l))
.unwrap_or_else(|| default.to_string());
st.locale.set(initial);
});
}
fn normalize(l: String) -> String {
l.replace('_', "-")
}
fn base_locale(l: &str) -> &str {
l.split("-u-").next().unwrap_or(l)
}
pub fn locale() -> Signal<String> {
ensure_state();
STATE.with(|s| s.borrow().as_ref().unwrap().locale)
}
pub fn set_locale(l: &str) {
locale().set(normalize(l.to_string()));
}
#[derive(Clone)]
pub enum FArg {
Str(String),
Num(f64),
SigStr(Signal<String>),
SigI64(Signal<i64>),
SigF64(Signal<f64>),
}
impl FArg {
fn resolve(&self) -> FluentValue<'static> {
match self {
FArg::Str(s) => FluentValue::from(s.clone()),
FArg::Num(n) => FluentValue::from(*n),
FArg::SigStr(s) => FluentValue::from(s.get()),
FArg::SigI64(s) => FluentValue::from(s.get()),
FArg::SigF64(s) => FluentValue::from(s.get()),
}
}
}
pub trait IntoFArg<M> {
fn into_farg(self) -> FArg;
}
pub struct ValM;
pub struct SigM;
impl IntoFArg<ValM> for &str {
fn into_farg(self) -> FArg {
FArg::Str(self.to_owned())
}
}
impl IntoFArg<ValM> for String {
fn into_farg(self) -> FArg {
FArg::Str(self)
}
}
impl IntoFArg<ValM> for i64 {
fn into_farg(self) -> FArg {
FArg::Num(self as f64)
}
}
impl IntoFArg<ValM> for f64 {
fn into_farg(self) -> FArg {
FArg::Num(self)
}
}
impl IntoFArg<SigM> for Signal<String> {
fn into_farg(self) -> FArg {
FArg::SigStr(self)
}
}
impl IntoFArg<SigM> for Signal<i64> {
fn into_farg(self) -> FArg {
FArg::SigI64(self)
}
}
impl IntoFArg<SigM> for Signal<f64> {
fn into_farg(self) -> FArg {
FArg::SigF64(self)
}
}
pub trait IntoNumberFArg<M>: IntoFArg<M> {}
impl IntoNumberFArg<ValM> for i64 {}
impl IntoNumberFArg<ValM> for f64 {}
impl IntoNumberFArg<SigM> for Signal<i64> {}
impl IntoNumberFArg<SigM> for Signal<f64> {}
fn message_from(
map: &HashMap<String, FluentBundle<FluentResource>>,
locale_name: &str,
key: &str,
args: &FluentArgs,
) -> Option<String> {
let bundle = map
.get(locale_name)
.or_else(|| map.get(base_locale(locale_name)))
.or_else(|| {
let lang = locale_name.split('-').next().unwrap_or(locale_name);
map.get(lang)
})
.or_else(|| {
let base = base_locale(locale_name);
let alias = match base {
"zh-Hans" | "zh-Hans-CN" | "zh-SG" => "zh-CN",
"zh-Hant" | "zh-Hant-TW" => "zh-TW",
_ => return None,
};
map.get(alias)
})?;
let (msg_key, attr) = match key.split_once('.') {
Some((m, a)) => (m, Some(a)),
None => (key, None),
};
let msg = bundle.get_message(msg_key)?;
let pattern = match attr {
Some(a) => msg.get_attribute(a)?.value(),
None => msg.value()?,
};
let mut errs = Vec::new();
Some(
bundle
.format_pattern(pattern, Some(args), &mut errs)
.into_owned(),
)
}
pub fn register_catalog(catalog: &'static Catalog) {
ensure_state();
STATE.with(|s| {
s.borrow_mut()
.as_mut()
.unwrap()
.scoped
.entry(catalog as *const Catalog as usize)
.or_insert_with(|| ScopedBundles {
catalog,
bundles: build_bundles(catalog.locales),
});
});
}
pub fn format_catalog(
catalog: &'static Catalog,
locale_name: &str,
key: &str,
args: &[(String, FArg)],
) -> String {
register_catalog(catalog);
STATE.with(|s| {
let st = s.borrow();
let scope = &st.as_ref().unwrap().scoped[&(catalog as *const Catalog as usize)];
resolve_scoped(scope, locale_name, key, args)
})
}
fn resolve_scoped(
scope: &ScopedBundles,
locale_name: &str,
key: &str,
args: &[(String, FArg)],
) -> String {
let mut fargs = FluentArgs::new();
for (k, v) in args {
fargs.set(k.clone(), v.resolve());
}
let base = locale_name.strip_suffix(PSEUDO_SUFFIX);
let lookup = base.unwrap_or(locale_name);
match message_from(&scope.bundles, lookup, key, &fargs)
.or_else(|| message_from(&scope.bundles, scope.catalog.default, key, &fargs))
{
Some(out) if base.is_some() => pseudolocalize(&out),
Some(out) => out,
None => format!("⟨{}::{key}⟩", scope.catalog.name),
}
}
pub fn format_in(locale_name: &str, key: &str, args: &[(String, FArg)]) -> String {
ensure_state();
STATE.with(|s| {
let st = s.borrow();
let st = st.as_ref().unwrap();
if let Some((name, local_key)) = key.rsplit_once("::") {
let mut matches = st.scoped.values().filter(|s| s.catalog.name == name);
if let Some(scope) = matches.next()
&& matches.next().is_none()
{
return resolve_scoped(scope, locale_name, local_key, args);
}
return format!("⟨{key}⟩");
}
let mut fargs = FluentArgs::new();
for (k, v) in args {
fargs.set(k.clone(), v.resolve());
}
let base = locale_name.strip_suffix(PSEUDO_SUFFIX);
let pseudo = base.is_some();
let lookup = base.unwrap_or(locale_name);
let out = message_from(&st.app, lookup, key, &fargs)
.or_else(|| message_from(&st.app, &st.default, key, &fargs))
.or_else(|| message_from(&st.core, lookup, key, &fargs))
.or_else(|| message_from(&st.core, "en", key, &fargs));
match out {
Some(out) => {
if pseudo {
pseudolocalize(&out)
} else {
out
}
}
None => format!("⟨{key}⟩"),
}
})
}
pub fn t(key: &str) -> String {
format_in(&locale().get(), key, &[])
}
const PSEUDO_SUFFIX: &str = "-XA";
fn is_pseudo(l: &str) -> bool {
l.ends_with(PSEUDO_SUFFIX)
}
fn pseudolocalize(s: &str) -> String {
let mut out: String = s
.chars()
.map(|c| match c {
'a' => 'á',
'e' => 'é',
'i' => 'í',
'o' => 'ó',
'u' => 'ú',
'y' => 'ý',
'A' => 'Á',
'E' => 'É',
'I' => 'Í',
'O' => 'Ó',
'U' => 'Ú',
'Y' => 'Ý',
other => other,
})
.collect();
out.push_str(" ・ロング");
out
}
pub fn strip_isolates(s: &str) -> String {
s.chars()
.filter(|c| !matches!(c, '\u{2068}' | '\u{2069}'))
.collect()
}
#[cfg(test)]
mod launch_locale_tests {
use super::*;
#[test]
fn install_picks_the_first_preference_it_can_serve() {
set_launch_locales(&[
"de-DE".to_string(), "fr-CA".to_string(), "en".to_string(),
]);
install(
"en",
&[("en", "hello = Hello\n"), ("fr", "hello = Bonjour\n")],
);
assert_eq!(locale().get(), "fr-CA");
assert_eq!(
t("hello"),
"Bonjour",
"and it resolves through the language half"
);
}
#[test]
fn an_explicit_override_outranks_the_device() {
set_launch_locales(&[]);
set_launch_locale("fr");
add_launch_locales(&["en-US".to_string()]);
install(
"en",
&[("en", "hello = Hello\n"), ("fr", "hello = Bonjour\n")],
);
assert_eq!(t("hello"), "Bonjour");
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn core_catalog_resolves_without_install_and_across_locales() {
assert_eq!(
strip_isolates(&format_in("en", key::day_cancel, &[])),
"Cancel"
);
assert_eq!(
strip_isolates(&format_in("fr", key::day_cancel, &[])),
"Annuler"
);
assert_eq!(
strip_isolates(&format_in("de", key::day_cancel, &[])),
"Abbrechen"
);
assert_eq!(strip_isolates(&format_in("ja", key::day_ok, &[])), "OK");
assert_eq!(strip_isolates(&format_in("xx", key::day_copy, &[])), "Copy");
assert_eq!(format_in("en", "nope", &[]), "⟨nope⟩");
}
#[test]
fn shortcut_key_attributes_resolve_and_fall_back_across_locales() {
install(
"en",
&[
("en", "menu_group = Group\n .key = g\n"),
("fr", "menu_group = Grouper\n"),
("de", "menu_group = Gruppieren\n .key = r\n"),
],
);
assert_eq!(strip_isolates(&format_in("en", "menu_group.key", &[])), "g");
assert_eq!(
strip_isolates(&format_in("fr", "menu_group.key", &[])),
"g",
"an omitted attribute falls back to the default locale's"
);
assert_eq!(
strip_isolates(&format_in("de", "menu_group.key", &[])),
"r",
"a locale may deliberately override"
);
assert_eq!(
strip_isolates(&format_in("fr", "menu_group", &[])),
"Grouper",
"the message value is untouched"
);
assert_eq!(format_in("en", "menu_group.nope", &[]), "⟨menu_group.nope⟩");
install("en", &[]); }
#[test]
fn menu_role_labels_and_interpolated_app_commands_localize() {
let app = |lang, key, val: &str| {
let out = format_in(lang, key, &[("app".to_string(), FArg::Str("Day".into()))]);
assert_eq!(strip_isolates(&out), val, "{lang}/{key}");
};
assert_eq!(
strip_isolates(&format_in("fr", key::day_cut, &[])),
"Couper"
);
assert_eq!(
strip_isolates(&format_in("de", key::day_paste, &[])),
"Einfügen"
);
assert_eq!(
strip_isolates(&format_in("es", key::day_select_all, &[])),
"Seleccionar todo"
);
app("en", key::day_quit_app, "Quit Day");
app("fr", key::day_quit_app, "Quitter Day");
app("de", key::day_quit_app, "Day beenden");
app("ja", key::day_quit_app, "Dayを終了");
app("fr", key::day_about_app, "À propos de Day");
}
#[test]
fn app_bundle_overrides_core_but_core_is_the_fallback() {
install("en", &[("en", "day_cancel = Dismiss\ngreeting = Hi")]);
assert_eq!(strip_isolates(&format_in("en", "greeting", &[])), "Hi");
assert_eq!(
strip_isolates(&format_in("en", key::day_cancel, &[])),
"Dismiss"
);
assert_eq!(strip_isolates(&format_in("en", key::day_ok, &[])), "OK");
}
}
#[cfg(test)]
mod scoped_tests {
use super::*;
static A: Catalog = Catalog {
name: "game_a",
default: "en",
locales: &[
(
"en",
"game_title = Alpha\nonly_a = Private\ngreeting = Hello { $name }\nmenu = Menu\n .key = a\n",
),
(
"fr",
"game_title = Alphabète\ngreeting = Bonjour { $name }\n",
),
("zh-CN", "game_title = 阿尔法\n"),
],
};
static B: Catalog = Catalog {
name: "game_b",
default: "en",
locales: &[("en", "game_title = Beta\n")],
};
#[test]
fn private_keys_do_not_leak_or_depend_on_registration_order() {
install(
"en",
&[("en", "game_title = Application\nonly_a = App impostor\n")],
);
for catalog in [&B, &A, &B, &A] {
let expected = if std::ptr::eq(catalog, &A) {
"Alpha"
} else {
"Beta"
};
assert_eq!(format_catalog(catalog, "en", "game_title", &[]), expected);
}
assert_eq!(format_in("en", "game_title", &[]), "Application");
assert_eq!(format_catalog(&B, "en", "only_a", &[]), "⟨game_b::only_a⟩");
assert_eq!(
format_catalog(&B, "en", key::day_ok, &[]),
"⟨game_b::day_ok⟩"
);
install("en", &[]);
assert_eq!(format_catalog(&A, "en", "game_title", &[]), "Alpha");
}
#[test]
fn scope_fallback_attributes_pseudo_and_regional_locales() {
assert_eq!(
format_catalog(&A, "fr-CA-u-nu-latn", "game_title", &[]),
"Alphabète"
);
assert_eq!(format_catalog(&A, "fr", "only_a", &[]), "Private");
assert_eq!(format_catalog(&A, "xx", "game_title", &[]), "Alpha");
assert_eq!(format_catalog(&A, "fr", "menu.key", &[]), "a");
assert_eq!(
format_catalog(&A, "fr-XA", "game_title", &[]),
pseudolocalize("Alphabète")
);
for locale in ["zh-CN", "zh-Hans", "zh-Hans-CN", "zh-SG"] {
assert_eq!(format_catalog(&A, locale, "game_title", &[]), "阿尔法");
}
}
#[test]
fn qualified_scripts_are_explicit_and_ambiguous_names_fail_closed() {
register_catalog(&A);
register_catalog(&B);
assert_eq!(format_in("en", "game_a::game_title", &[]), "Alpha");
assert_eq!(format_in("en", "game_b::game_title", &[]), "Beta");
assert_eq!(
format_in("en", "absent::game_title", &[]),
"⟨absent::game_title⟩"
);
static DUPLICATE: Catalog = Catalog {
name: "game_a",
default: "en",
locales: &[("en", "game_title = Other version\n")],
};
register_catalog(&DUPLICATE);
assert_eq!(
format_in("en", "game_a::game_title", &[]),
"⟨game_a::game_title⟩"
);
assert_eq!(format_catalog(&A, "en", "game_title", &[]), "Alpha");
assert_eq!(
format_catalog(&DUPLICATE, "en", "game_title", &[]),
"Other version"
);
}
#[test]
fn shared_locale_and_signal_arguments_react_without_reinstalling_catalogs() {
use std::rc::Rc;
set_locale("en");
let name = Signal::new("Ada".to_string());
let observed = Rc::new(RefCell::new(Vec::new()));
let values = observed.clone();
let scope = day_reactive::Scope::child();
scope.enter(|| {
day_reactive::Effect::new(move || {
values.borrow_mut().push(strip_isolates(&format_catalog(
&A,
&locale().get(),
"greeting",
&[("name".into(), FArg::SigStr(name))],
)));
});
});
day_reactive::batch(|| set_locale("fr"));
day_reactive::batch(|| name.set("Grace".to_string()));
assert_eq!(
*observed.borrow(),
["Hello Ada", "Bonjour Ada", "Bonjour Grace"]
);
scope.dispose();
}
}