1use std::{
7 borrow::Cow,
8 cmp::Reverse,
9 collections::HashMap,
10 sync::{Arc, OnceLock},
11};
12
13use fluent_bundle::{FluentArgs, FluentResource, concurrent::FluentBundle};
14use tessera_ui::{EntryRegistry, Plugin, State, StateOwner, TesseraPackage, try_with_plugin};
15
16pub use fluent_bundle::FluentValue;
17pub use fluent_templates::{self, LanguageIdentifier, Loader, langid};
18
19pub type Arguments<'a> = HashMap<Cow<'static, str>, FluentValue<'a>>;
21
22pub struct EmbeddedLoader {
26 locales: Vec<EmbeddedLocale>,
27 fallback: LanguageIdentifier,
28}
29
30struct EmbeddedLocale {
31 id: LanguageIdentifier,
32 sources: &'static [&'static str],
33 bundle: OnceLock<FluentBundle<FluentResource>>,
34}
35
36impl EmbeddedLoader {
37 pub fn new(
42 locales: &'static [(&'static str, &'static [&'static str])],
43 fallback: LanguageIdentifier,
44 ) -> Self {
45 Self {
46 locales: locales
47 .iter()
48 .map(|&(id, sources)| EmbeddedLocale {
49 id: id.parse().expect("invalid locale identifier"),
50 sources,
51 bundle: OnceLock::new(),
52 })
53 .collect(),
54 fallback,
55 }
56 }
57
58 fn negotiate(&self, requested: &LanguageIdentifier) -> Vec<&EmbeddedLocale> {
59 let mut available = self.locales.iter().collect::<Vec<_>>();
60 let mut matches = Vec::new();
61 for available_as_range in [false, true] {
62 available.retain(|locale| {
63 let matched = locale.id.matches(requested, available_as_range, false);
64 if matched {
65 matches.push(*locale);
66 }
67 !matched
68 });
69 }
70 matches.sort_by_key(|locale| {
71 Reverse(
72 usize::from(locale.id.script.is_some())
73 + usize::from(locale.id.region.is_some())
74 + locale.id.variants().len(),
75 )
76 });
77 matches
78 }
79
80 fn format(locale: &EmbeddedLocale, id: &str, args: Option<&Arguments<'_>>) -> Option<String> {
81 let bundle = locale.bundle.get_or_init(|| {
82 let mut bundle = FluentBundle::new_concurrent(vec![locale.id.clone()]);
83 for source in locale.sources {
84 let resource = FluentResource::try_new((*source).to_owned())
85 .unwrap_or_else(|_| panic!("invalid Fluent source for {}", locale.id));
86 bundle
87 .add_resource(resource)
88 .unwrap_or_else(|_| panic!("duplicate Fluent message for {}", locale.id));
89 }
90 bundle
91 });
92 let (message, attribute) = match id.split_once('.') {
93 Some((message, attribute)) => (message, Some(attribute)),
94 None => (id, None),
95 };
96 let message = bundle.get_message(message)?;
97 let pattern = match attribute {
98 Some(attribute) => message
99 .attributes()
100 .find(|candidate| candidate.id() == attribute)?
101 .value(),
102 None => message.value()?,
103 };
104 let args = args.map(|args| {
105 args.iter()
106 .map(|(key, value)| (key.as_ref(), value.clone()))
107 .collect::<FluentArgs<'_>>()
108 });
109 let mut errors = Vec::new();
110 let value = bundle.format_pattern(pattern, args.as_ref(), &mut errors);
111 errors.is_empty().then(|| value.into_owned())
112 }
113}
114
115impl Loader for EmbeddedLoader {
116 fn lookup_complete(
117 &self,
118 lang: &LanguageIdentifier,
119 text_id: &str,
120 args: Option<&Arguments<'_>>,
121 ) -> String {
122 self.try_lookup_complete(lang, text_id, args)
123 .unwrap_or_else(|| format!("Unknown localization key: {text_id:?}"))
124 }
125
126 fn try_lookup_complete(
127 &self,
128 lang: &LanguageIdentifier,
129 text_id: &str,
130 args: Option<&Arguments<'_>>,
131 ) -> Option<String> {
132 self.negotiate(lang)
133 .into_iter()
134 .find_map(|locale| Self::format(locale, text_id, args))
135 .or_else(|| {
136 let fallback = self
137 .locales
138 .iter()
139 .find(|locale| locale.id == self.fallback);
140 (*lang != self.fallback)
141 .then_some(fallback)
142 .flatten()
143 .and_then(|locale| Self::format(locale, text_id, args))
144 })
145 }
146
147 fn locales(&self) -> Box<dyn Iterator<Item = &LanguageIdentifier> + '_> {
148 Box::new(self.locales.iter().map(|locale| &locale.id))
149 }
150}
151
152#[derive(Clone)]
154pub struct Catalog {
155 loader: Arc<dyn Loader + Send + Sync>,
156 default_locale: LanguageIdentifier,
157}
158
159impl Catalog {
160 pub fn new(
162 loader: impl Loader + Send + Sync + 'static,
163 default_locale: LanguageIdentifier,
164 ) -> Self {
165 Self {
166 loader: Arc::new(loader),
167 default_locale,
168 }
169 }
170
171 pub fn locale(mut self, locale: LanguageIdentifier) -> Self {
173 self.default_locale = locale;
174 self
175 }
176
177 pub fn text(&self, id: impl AsRef<str>) -> String {
182 let id = id.as_ref();
183 self.lookup(id, None).unwrap_or_else(|| id.to_owned())
184 }
185
186 pub fn format<'a>(
188 &self,
189 id: &str,
190 args: impl IntoIterator<Item = (&'static str, FluentValue<'a>)>,
191 ) -> String {
192 let args = args
193 .into_iter()
194 .map(|(key, value)| (Cow::Borrowed(key), value))
195 .collect();
196 self.lookup(id, Some(&args))
197 .unwrap_or_else(|| id.to_owned())
198 }
199
200 fn lookup(&self, id: &str, args: Option<&Arguments<'_>>) -> Option<String> {
201 if let Some(i18n) = I18n::try_current() {
202 let (application, locale) = i18n.snapshot();
203 if !Arc::ptr_eq(&application, &self.loader) {
204 if let Some(value) = application.try_lookup_complete(&locale, id, args) {
205 return Some(value);
206 }
207 }
208 self.loader.try_lookup_complete(&locale, id, args)
209 } else {
210 self.loader
211 .try_lookup_complete(&self.default_locale, id, args)
212 }
213 }
214}
215
216struct Localization {
217 catalog: Catalog,
218 locale: LanguageIdentifier,
219}
220
221struct LocalizationPlugin {
222 _owner: StateOwner,
223 current: I18n,
224}
225
226impl Plugin for LocalizationPlugin {}
227
228impl TesseraPackage for Catalog {
229 fn register(self, registry: &mut EntryRegistry) {
230 let owner = StateOwner::new();
231 let state = owner.state_with_key((), || Localization {
232 locale: self.default_locale.clone(),
233 catalog: self,
234 });
235 registry.register_plugin(LocalizationPlugin {
236 _owner: owner,
237 current: I18n(state),
238 });
239 }
240}
241
242#[derive(Clone, Copy, PartialEq)]
244pub struct I18n(State<Localization>);
245
246impl I18n {
247 pub fn current() -> Self {
251 Self::try_current().expect("register a localization catalog with EntryPoint::package")
252 }
253
254 pub fn try_current() -> Option<Self> {
256 try_with_plugin::<LocalizationPlugin, _>(|plugin| plugin.current)
257 }
258
259 pub fn locale(self) -> LanguageIdentifier {
261 self.0.with(|localization| localization.locale.clone())
262 }
263
264 pub fn set_locale(self, locale: LanguageIdentifier) {
266 if self.locale() != locale {
267 self.0.with_mut(|localization| localization.locale = locale);
268 }
269 }
270
271 pub fn locales(self) -> Vec<LanguageIdentifier> {
273 let (loader, _) = self.snapshot();
274 loader.locales().cloned().collect()
275 }
276
277 fn snapshot(self) -> (Arc<dyn Loader + Send + Sync>, LanguageIdentifier) {
278 self.0.with(|localization| {
279 (
280 Arc::clone(&localization.catalog.loader),
281 localization.locale.clone(),
282 )
283 })
284 }
285}
286
287#[cfg(test)]
288mod tests {
289 use super::*;
290
291 static LOCALES: &[(&str, &[&str])] = &[
292 (
293 "en",
294 &[
295 "greeting = Hello\n .title = Welcome\n",
296 "only-en = Fallback\n",
297 ],
298 ),
299 ("zh-CN", &["greeting = 你好\n"]),
300 ];
301
302 #[test]
303 fn embedded_locales_parse_on_first_lookup_and_fall_back_per_message() {
304 let loader = EmbeddedLoader::new(LOCALES, langid!("en"));
305 assert_eq!(
306 loader
307 .try_lookup(&langid!("en"), "greeting.title")
308 .as_deref(),
309 Some("Welcome")
310 );
311 assert!(loader.locales[1].bundle.get().is_none());
312 assert_eq!(
313 loader.try_lookup(&langid!("zh-CN"), "greeting").as_deref(),
314 Some("你好")
315 );
316 assert_eq!(
317 loader.try_lookup(&langid!("zh-CN"), "only-en").as_deref(),
318 Some("Fallback")
319 );
320 assert_eq!(
321 loader.try_lookup(&langid!("fr"), "greeting").as_deref(),
322 Some("Hello")
323 );
324 assert_eq!(loader.try_lookup(&langid!("en"), "missing"), None);
325 }
326}