1use std::{
2 collections::HashMap,
3 path::PathBuf,
4};
5
6use fluent::{
7 FluentArgs,
8 FluentBundle,
9 FluentResource,
10};
11use freya_core::prelude::*;
12use unic_langid::LanguageIdentifier;
13
14use super::error::Error;
15
16#[cfg_attr(test, derive(Debug, PartialEq))]
18pub struct Locale {
19 id: LanguageIdentifier,
20 resource: LocaleResource,
21}
22
23impl Locale {
24 pub fn new_static(id: LanguageIdentifier, str: &'static str) -> Self {
25 Self {
26 id,
27 resource: LocaleResource::Static(str),
28 }
29 }
30
31 pub fn new_dynamic(id: LanguageIdentifier, path: impl Into<PathBuf>) -> Self {
32 Self {
33 id,
34 resource: LocaleResource::Path(path.into()),
35 }
36 }
37}
38
39impl<T> From<(LanguageIdentifier, T)> for Locale
40where
41 T: Into<LocaleResource>,
42{
43 fn from((id, resource): (LanguageIdentifier, T)) -> Self {
44 let resource = resource.into();
45 Self { id, resource }
46 }
47}
48
49#[derive(Debug, PartialEq)]
51pub enum LocaleResource {
52 Static(&'static str),
53
54 Path(PathBuf),
55}
56
57impl LocaleResource {
58 pub fn try_to_resource_string(&self) -> Result<String, Error> {
59 match self {
60 Self::Static(str) => Ok(str.to_string()),
61
62 Self::Path(path) => std::fs::read_to_string(path)
63 .map_err(|e| Error::LocaleResourcePathReadFailed(e.to_string())),
64 }
65 }
66
67 pub fn to_resource_string(&self) -> String {
68 let result = self.try_to_resource_string();
69 match result {
70 Ok(string) => string,
71 Err(err) => panic!("failed to create resource string {self:?}: {err}"),
72 }
73 }
74}
75
76impl From<&'static str> for LocaleResource {
77 fn from(value: &'static str) -> Self {
78 Self::Static(value)
79 }
80}
81
82impl From<PathBuf> for LocaleResource {
83 fn from(value: PathBuf) -> Self {
84 Self::Path(value)
85 }
86}
87
88#[derive(Debug, PartialEq)]
90pub struct I18nConfig {
91 pub id: LanguageIdentifier,
93
94 pub fallback: Option<LanguageIdentifier>,
97
98 pub locale_resources: Vec<LocaleResource>,
100
101 pub locales: HashMap<LanguageIdentifier, usize>,
103}
104
105impl I18nConfig {
106 pub fn new(id: LanguageIdentifier) -> Self {
108 Self {
109 id,
110 fallback: None,
111 locale_resources: Vec::new(),
112 locales: HashMap::new(),
113 }
114 }
115
116 pub fn with_fallback(mut self, fallback: LanguageIdentifier) -> Self {
118 self.fallback = Some(fallback);
119 self
120 }
121
122 pub fn with_locale<T>(mut self, locale: T) -> Self
127 where
128 T: Into<Locale>,
129 {
130 let locale = locale.into();
131 let locale_resources_len = self.locale_resources.len();
132
133 let index = self
134 .locale_resources
135 .iter()
136 .position(|r| *r == locale.resource)
137 .unwrap_or(locale_resources_len);
138
139 if index == locale_resources_len {
140 self.locale_resources.push(locale.resource)
141 };
142
143 self.locales.insert(locale.id, index);
144 self
145 }
146
147 #[cfg(feature = "discovery")]
156 pub fn try_with_auto_locales(self, path: PathBuf) -> Result<Self, Error> {
157 if path.is_dir() {
158 let files = find_ftl_files(&path)?;
159 files
160 .into_iter()
161 .try_fold(self, |acc, file| acc.with_auto_pathbuf(file))
162 } else if is_ftl_file(&path) {
163 self.with_auto_pathbuf(path)
164 } else {
165 Err(Error::InvalidPath(path.to_string_lossy().to_string()))
166 }
167 }
168
169 #[cfg(feature = "discovery")]
170 fn with_auto_pathbuf(self, file: PathBuf) -> Result<Self, Error> {
171 assert!(is_ftl_file(&file));
172
173 let stem = file.file_stem().ok_or_else(|| {
174 Error::InvalidLanguageId(format!("No file stem: '{}'", file.display()))
175 })?;
176
177 let id_str = stem.to_str().ok_or_else(|| {
178 Error::InvalidLanguageId(format!("Cannot convert: {}", stem.to_string_lossy()))
179 })?;
180
181 let id = LanguageIdentifier::from_bytes(id_str.as_bytes())
182 .map_err(|e| Error::InvalidLanguageId(e.to_string()))?;
183
184 Ok(self.with_locale((id, file)))
185 }
186
187 #[cfg(feature = "discovery")]
191 pub fn with_auto_locales(self, path: PathBuf) -> Self {
192 let path_name = path.display().to_string();
193 let result = self.try_with_auto_locales(path);
194 match result {
195 Ok(result) => result,
196 Err(err) => panic!("with_auto_locales must have valid pathbuf {path_name}: {err}",),
197 }
198 }
199}
200
201#[cfg(feature = "discovery")]
202fn find_ftl_files(folder: &PathBuf) -> Result<Vec<PathBuf>, Error> {
203 let ftl_files: Vec<PathBuf> = walkdir::WalkDir::new(folder)
204 .into_iter()
205 .filter_map(|entry| entry.ok())
206 .filter(|entry| is_ftl_file(entry.path()))
207 .map(|entry| entry.path().to_path_buf())
208 .collect();
209
210 Ok(ftl_files)
211}
212
213#[cfg(feature = "discovery")]
214fn is_ftl_file(entry: &std::path::Path) -> bool {
215 entry.is_file() && entry.extension().map(|ext| ext == "ftl").unwrap_or(false)
216}
217
218pub fn use_share_i18n(i18n: impl FnOnce() -> I18n) {
222 use_provide_context(i18n);
223}
224
225pub fn use_init_i18n(init: impl FnOnce() -> I18nConfig) -> I18n {
229 use_provide_context(move || {
230 match I18n::create(init()) {
232 Ok(i18n) => i18n,
233 Err(e) => panic!("Failed to create I18n context: {e}"),
234 }
235 })
236}
237
238#[derive(Clone, Copy)]
239pub struct I18n {
240 selected_language: State<LanguageIdentifier>,
241 fallback_language: State<Option<LanguageIdentifier>>,
242 locale_resources: State<Vec<LocaleResource>>,
243 locales: State<HashMap<LanguageIdentifier, usize>>,
244 active_bundle: State<FluentBundle<FluentResource>>,
245}
246
247impl I18n {
248 pub fn try_get() -> Option<Self> {
249 try_consume_context()
250 }
251
252 pub fn get() -> Self {
253 consume_context()
254 }
255
256 pub fn create(
257 I18nConfig {
258 id: selected_language,
259 fallback: fallback_language,
260 locale_resources,
261 locales,
262 }: I18nConfig,
263 ) -> Result<Self, Error> {
264 let bundle = try_create_bundle(
265 &selected_language,
266 &fallback_language,
267 &locale_resources,
268 &locales,
269 )?;
270 Ok(Self {
271 selected_language: State::create(selected_language),
272 fallback_language: State::create(fallback_language),
273 locale_resources: State::create(locale_resources),
274 locales: State::create(locales),
275 active_bundle: State::create(bundle),
276 })
277 }
278
279 pub fn create_global(
280 I18nConfig {
281 id: selected_language,
282 fallback: fallback_language,
283 locale_resources,
284 locales,
285 }: I18nConfig,
286 ) -> Result<Self, Error> {
287 let bundle = try_create_bundle(
288 &selected_language,
289 &fallback_language,
290 &locale_resources,
291 &locales,
292 )?;
293 Ok(Self {
294 selected_language: State::create_global(selected_language),
295 fallback_language: State::create_global(fallback_language),
296 locale_resources: State::create_global(locale_resources),
297 locales: State::create_global(locales),
298 active_bundle: State::create_global(bundle),
299 })
300 }
301
302 pub fn try_translate_with_args(
303 &self,
304 msg: &str,
305 args: Option<&FluentArgs>,
306 ) -> Result<String, Error> {
307 let (message_id, attribute_name) = Self::decompose_identifier(msg)?;
308
309 let bundle = self.active_bundle.read();
310
311 let message = bundle
312 .get_message(message_id)
313 .ok_or_else(|| Error::MessageIdNotFound(message_id.into()))?;
314
315 let pattern = if let Some(attribute_name) = attribute_name {
316 let attribute = message
317 .get_attribute(attribute_name)
318 .ok_or_else(|| Error::AttributeIdNotFound(msg.to_string()))?;
319 attribute.value()
320 } else {
321 message
322 .value()
323 .ok_or_else(|| Error::MessagePatternNotFound(message_id.into()))?
324 };
325
326 let mut errors = vec![];
327 let translation = bundle
328 .format_pattern(pattern, args, &mut errors)
329 .to_string();
330
331 (errors.is_empty())
332 .then_some(translation)
333 .ok_or_else(|| Error::FluentErrorsDetected(format!("{errors:#?}")))
334 }
335
336 pub fn decompose_identifier(msg: &str) -> Result<(&str, Option<&str>), Error> {
337 let parts: Vec<&str> = msg.split('.').collect();
338 match parts.as_slice() {
339 [message_id] => Ok((message_id, None)),
340 [message_id, attribute_name] => Ok((message_id, Some(attribute_name))),
341 _ => Err(Error::InvalidMessageId(msg.to_string())),
342 }
343 }
344
345 pub fn translate_with_args(&self, msg: &str, args: Option<&FluentArgs>) -> String {
346 let result = self.try_translate_with_args(msg, args);
347 match result {
348 Ok(translation) => translation,
349 Err(err) => panic!("Failed to translate {msg}: {err}"),
350 }
351 }
352
353 #[inline]
354 pub fn try_translate(&self, msg: &str) -> Result<String, Error> {
355 self.try_translate_with_args(msg, None)
356 }
357
358 pub fn translate(&self, msg: &str) -> String {
359 let result = self.try_translate(msg);
360 match result {
361 Ok(translation) => translation,
362 Err(err) => panic!("Failed to translate {msg}: {err}"),
363 }
364 }
365
366 #[inline]
368 pub fn language(&self) -> LanguageIdentifier {
369 self.selected_language.read().clone()
370 }
371
372 pub fn fallback_language(&self) -> Option<LanguageIdentifier> {
374 self.fallback_language.read().clone()
375 }
376
377 pub fn try_set_language(&mut self, id: LanguageIdentifier) -> Result<(), Error> {
379 *self.selected_language.write() = id;
380 self.try_update_active_bundle()
381 }
382
383 pub fn set_language(&mut self, id: LanguageIdentifier) {
385 let id_name = id.to_string();
386 let result = self.try_set_language(id);
387 match result {
388 Ok(()) => (),
389 Err(err) => panic!("cannot set language {id_name}: {err}"),
390 }
391 }
392
393 pub fn try_set_fallback_language(&mut self, id: LanguageIdentifier) -> Result<(), Error> {
395 self.locales
396 .read()
397 .get(&id)
398 .ok_or_else(|| Error::FallbackMustHaveLocale(id.to_string()))?;
399
400 *self.fallback_language.write() = Some(id);
401 self.try_update_active_bundle()
402 }
403
404 pub fn set_fallback_language(&mut self, id: LanguageIdentifier) {
406 let id_name = id.to_string();
407 let result = self.try_set_fallback_language(id);
408 match result {
409 Ok(()) => (),
410 Err(err) => panic!("cannot set fallback language {id_name}: {err}"),
411 }
412 }
413
414 fn try_update_active_bundle(&mut self) -> Result<(), Error> {
415 let bundle = try_create_bundle(
416 &self.selected_language.peek(),
417 &self.fallback_language.peek(),
418 &self.locale_resources.peek(),
419 &self.locales.peek(),
420 )?;
421
422 self.active_bundle.set(bundle);
423 Ok(())
424 }
425}
426
427fn try_create_bundle(
428 selected_language: &LanguageIdentifier,
429 fallback_language: &Option<LanguageIdentifier>,
430 locale_resources: &[LocaleResource],
431 locales: &HashMap<LanguageIdentifier, usize>,
432) -> Result<FluentBundle<FluentResource>, Error> {
433 let add_resource = move |bundle: &mut FluentBundle<FluentResource>,
434 langid: &LanguageIdentifier,
435 locale_resources: &[LocaleResource]| {
436 if let Some(&i) = locales.get(langid) {
437 let resource = &locale_resources[i];
438 let resource =
439 FluentResource::try_new(resource.try_to_resource_string()?).map_err(|e| {
440 Error::FluentErrorsDetected(format!("resource langid: {langid}\n{e:#?}"))
441 })?;
442 bundle.add_resource_overriding(resource);
443 };
444 Ok(())
445 };
446
447 let mut bundle = FluentBundle::new(vec![selected_language.clone()]);
448 if let Some(fallback_language) = fallback_language {
449 add_resource(&mut bundle, fallback_language, locale_resources)?;
450 }
451
452 let (language, script, region, variants) = selected_language.clone().into_parts();
453 let variants_lang = LanguageIdentifier::from_parts(language, script, region, &variants);
454 let region_lang = LanguageIdentifier::from_parts(language, script, region, &[]);
455 let script_lang = LanguageIdentifier::from_parts(language, script, None, &[]);
456 let language_lang = LanguageIdentifier::from_parts(language, None, None, &[]);
457
458 add_resource(&mut bundle, &language_lang, locale_resources)?;
459 add_resource(&mut bundle, &script_lang, locale_resources)?;
460 add_resource(&mut bundle, ®ion_lang, locale_resources)?;
461 add_resource(&mut bundle, &variants_lang, locale_resources)?;
462
463 Ok(bundle)
472}