1use std::collections::HashMap;
36use std::sync::Arc;
37use std::{fs, io};
38
39use tracing::{debug, error, warn};
40
41use crate::{Application, ArcStr, Env};
42
43use fluent_bundle::{
44 FluentArgs, FluentBundle, FluentError, FluentMessage, FluentResource, FluentValue,
45};
46use fluent_langneg::{negotiate_languages, NegotiationStrategy};
47use fluent_syntax::ast::Pattern as FluentPattern;
48use unic_langid::LanguageIdentifier;
49
50static FALLBACK_STRINGS: &str = include_str!("../resources/i18n/en-US/builtin.ftl");
55
56#[allow(dead_code)]
58pub(crate) struct L10nManager {
59 res_mgr: ResourceManager,
62 resources: Vec<String>,
63 current_bundle: BundleStack,
64 current_locale: LanguageIdentifier,
65}
66
67struct ResourceManager {
69 resources: HashMap<String, Arc<FluentResource>>,
70 locales: Vec<LanguageIdentifier>,
71 default_locale: LanguageIdentifier,
72 path_scheme: String,
73}
74
75type ArgClosure<T> = Arc<dyn Fn(&T, &Env) -> FluentValue<'static> + 'static>;
79
80#[derive(Clone)]
82struct ArgSource<T>(ArgClosure<T>);
83
84#[derive(Debug, Clone)]
89pub struct LocalizedString<T> {
90 pub(crate) key: &'static str,
91 placeholder: Option<ArcStr>,
92 args: Option<Vec<(&'static str, ArgSource<T>)>>,
93 resolved: Option<ArcStr>,
94 resolved_lang: Option<LanguageIdentifier>,
95}
96
97struct BundleStack(Vec<FluentBundle<Arc<FluentResource>>>);
99
100impl BundleStack {
101 fn get_message(&self, id: &str) -> Option<FluentMessage> {
102 self.0.iter().flat_map(|b| b.get_message(id)).next()
103 }
104
105 fn format_pattern(
106 &self,
107 id: &str,
108 pattern: &FluentPattern<&str>,
109 args: Option<&FluentArgs>,
110 errors: &mut Vec<FluentError>,
111 ) -> String {
112 for bundle in self.0.iter() {
113 if bundle.has_message(id) {
114 return bundle.format_pattern(pattern, args, errors).to_string();
115 }
116 }
117 format!("localization failed for key '{id}'")
118 }
119}
120
121impl ResourceManager {
123 fn get_resource(&mut self, res_id: &str, locale: &str) -> Arc<FluentResource> {
125 let path = self
126 .path_scheme
127 .replace("{locale}", locale)
128 .replace("{res_id}", res_id);
129 if let Some(res) = self.resources.get(&path) {
130 res.clone()
131 } else {
132 let string = fs::read_to_string(&path).unwrap_or_else(|_| {
133 if (res_id, locale) == ("builtin.ftl", "en-US") {
134 FALLBACK_STRINGS.to_string()
135 } else {
136 error!("missing resource {}/{}", locale, res_id);
137 String::new()
138 }
139 });
140 let res = match FluentResource::try_new(string) {
141 Ok(res) => Arc::new(res),
142 Err((res, _err)) => Arc::new(res),
143 };
144 self.resources.insert(path, res.clone());
145 res
146 }
147 }
148
149 fn get_bundle(&mut self, locale: &LanguageIdentifier, resource_ids: &[String]) -> BundleStack {
151 let resolved_locales = self.resolve_locales(locale.clone());
152 debug!("resolved: {}", PrintLocales(resolved_locales.as_slice()));
153 let mut stack = Vec::new();
154 for locale in &resolved_locales {
155 let mut bundle = FluentBundle::new(resolved_locales.clone());
156 for res_id in resource_ids {
157 let res = self.get_resource(res_id, &locale.to_string());
158 bundle.add_resource(res).unwrap();
159 }
160 stack.push(bundle);
161 }
162 BundleStack(stack)
163 }
164
165 pub(crate) fn resolve_locales(&self, locale: LanguageIdentifier) -> Vec<LanguageIdentifier> {
167 negotiate_languages(
168 &[locale],
169 &self.locales,
170 Some(&self.default_locale),
171 NegotiationStrategy::Filtering,
172 )
173 .into_iter()
174 .map(|l| l.to_owned())
175 .collect()
176 }
177}
178
179impl L10nManager {
180 pub fn new(resources: Vec<String>, base_dir: &str) -> Self {
189 fn get_available_locales(base_dir: &str) -> Result<Vec<LanguageIdentifier>, io::Error> {
190 let mut locales = vec![];
191
192 let res_dir = fs::read_dir(base_dir)?;
193 for entry in res_dir.flatten() {
194 let path = entry.path();
195 if path.is_dir() {
196 if let Some(name) = path.file_name() {
197 if let Some(name) = name.to_str() {
198 let langid: LanguageIdentifier = name.parse().expect("Parsing failed.");
199 locales.push(langid);
200 }
201 }
202 }
203 }
204 Ok(locales)
205 }
206
207 let default_locale: LanguageIdentifier =
208 "en-US".parse().expect("failed to parse default locale");
209 let current_locale = Application::get_locale()
210 .parse()
211 .unwrap_or_else(|_| default_locale.clone());
212 let locales = get_available_locales(base_dir).unwrap_or_default();
213 debug!(
214 "available locales {}, current {}",
215 PrintLocales(&locales),
216 current_locale,
217 );
218 let mut path_scheme = base_dir.to_string();
219 path_scheme.push_str("/{locale}/{res_id}");
220
221 let mut res_mgr = ResourceManager {
222 resources: HashMap::new(),
223 path_scheme,
224 default_locale,
225 locales,
226 };
227
228 let current_bundle = res_mgr.get_bundle(¤t_locale, &resources);
229
230 L10nManager {
231 res_mgr,
232 resources,
233 current_bundle,
234 current_locale,
235 }
236 }
237
238 pub fn localize<'args>(
247 &'args self,
248 key: &str,
249 args: impl Into<Option<&'args FluentArgs<'args>>>,
250 ) -> Option<ArcStr> {
251 let args = args.into();
252 let value = match self
253 .current_bundle
254 .get_message(key)
255 .and_then(|msg| msg.value())
256 {
257 Some(v) => v,
258 None => return None,
259 };
260 let mut errs = Vec::new();
261 let result = self
262 .current_bundle
263 .format_pattern(key, value, args, &mut errs);
264 for err in errs {
265 warn!("localization error {:?}", err);
266 }
267
268 const START_ISOLATE: char = '\u{2068}';
272 const END_ISOLATE: char = '\u{2069}';
273 if args.is_some() && result.chars().any(|c| c == START_ISOLATE) {
274 Some(
275 result
276 .chars()
277 .filter(|c| c != &START_ISOLATE && c != &END_ISOLATE)
278 .collect::<String>()
279 .into(),
280 )
281 } else {
282 Some(result.into())
283 }
284 }
285 }
287
288impl std::fmt::Debug for L10nManager {
289 fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
290 f.debug_struct("L10nManager")
291 .field("resources", &self.resources)
292 .field("res_mgr.locales", &self.res_mgr.locales)
293 .field("current_locale", &self.current_locale)
294 .finish()
295 }
296}
297
298impl<T> LocalizedString<T> {
299 pub const fn new(key: &'static str) -> Self {
301 LocalizedString {
302 key,
303 args: None,
304 placeholder: None,
305 resolved: None,
306 resolved_lang: None,
307 }
308 }
309
310 pub fn with_placeholder(mut self, placeholder: impl Into<ArcStr>) -> Self {
314 self.placeholder = Some(placeholder.into());
315 self
316 }
317
318 pub fn localized_str(&self) -> ArcStr {
321 self.resolved
322 .clone()
323 .or_else(|| self.placeholder.clone())
324 .unwrap_or_else(|| self.key.into())
325 }
326
327 pub fn with_arg(
331 mut self,
332 key: &'static str,
333 f: impl Fn(&T, &Env) -> FluentValue<'static> + 'static,
334 ) -> Self {
335 self.args
336 .get_or_insert(Vec::new())
337 .push((key, ArgSource(Arc::new(f))));
338 self
339 }
340
341 pub fn resolve(&mut self, data: &T, env: &Env) -> bool {
346 let manager = match env.localization_manager() {
350 Some(manager) => manager,
351 None => return false,
352 };
353
354 if self.args.is_some() || self.resolved_lang.as_ref() != Some(&manager.current_locale) {
355 let args: Option<FluentArgs> = self
356 .args
357 .as_ref()
358 .map(|a| a.iter().map(|(k, v)| (*k, (v.0)(data, env))).collect());
359
360 self.resolved_lang = Some(manager.current_locale.clone());
361 let next = manager.localize(self.key, args.as_ref());
362 let result = next != self.resolved;
363 self.resolved = next;
364 result
365 } else {
366 false
367 }
368 }
369}
370
371impl<T> std::fmt::Debug for ArgSource<T> {
372 fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
373 write!(f, "Arg Resolver {:p}", self.0)
374 }
375}
376
377struct PrintLocales<'a, T>(&'a [T]);
379
380impl<'a, T: std::fmt::Display> std::fmt::Display for PrintLocales<'a, T> {
381 fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
382 write!(f, "[")?;
383 let mut prev = false;
384 for l in self.0 {
385 if prev {
386 write!(f, ", ")?;
387 }
388 prev = true;
389 write!(f, "{l}")?;
390 }
391 write!(f, "]")
392 }
393}
394
395#[cfg(test)]
396mod tests {
397 use super::*;
398 use test_log::test;
399
400 #[test]
401 fn resolve() {
402 let en_us: LanguageIdentifier = "en-US".parse().unwrap();
403 let en_ca: LanguageIdentifier = "en-CA".parse().unwrap();
404 let en_gb: LanguageIdentifier = "en-GB".parse().unwrap();
405 let fr_fr: LanguageIdentifier = "fr-FR".parse().unwrap();
406 let pt_pt: LanguageIdentifier = "pt-PT".parse().unwrap();
407
408 let resmgr = ResourceManager {
409 resources: HashMap::new(),
410 locales: vec![en_us.clone(), en_ca.clone(), en_gb.clone(), fr_fr.clone()],
411 default_locale: en_us.clone(),
412 path_scheme: String::new(),
413 };
414
415 let en_za: LanguageIdentifier = "en-GB".parse().unwrap();
416 let cn_hk: LanguageIdentifier = "cn-HK".parse().unwrap();
417 let fr_ca: LanguageIdentifier = "fr-CA".parse().unwrap();
418
419 assert_eq!(
420 resmgr.resolve_locales(en_ca.clone()),
421 vec![en_ca.clone(), en_us.clone(), en_gb.clone()]
422 );
423 assert_eq!(
424 resmgr.resolve_locales(en_za),
425 vec![en_gb, en_us.clone(), en_ca]
426 );
427 assert_eq!(
428 resmgr.resolve_locales(fr_ca),
429 vec![fr_fr.clone(), en_us.clone()]
430 );
431 assert_eq!(
432 resmgr.resolve_locales(fr_fr.clone()),
433 vec![fr_fr, en_us.clone()]
434 );
435 assert_eq!(resmgr.resolve_locales(cn_hk), vec![en_us.clone()]);
436 assert_eq!(resmgr.resolve_locales(pt_pt), vec![en_us]);
437 }
438}