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_init_i18n(init: impl FnOnce() -> I18nConfig) -> I18n {
222 use_provide_context(move || {
223 let I18nConfig {
225 id,
226 fallback,
227 locale_resources,
228 locales,
229 } = init();
230
231 match I18n::new(id, fallback, locale_resources, locales) {
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 new(
257 selected_language: LanguageIdentifier,
258 fallback_language: Option<LanguageIdentifier>,
259 locale_resources: Vec<LocaleResource>,
260 locales: HashMap<LanguageIdentifier, usize>,
261 ) -> Result<Self, Error> {
262 let bundle = try_create_bundle(
263 &selected_language,
264 &fallback_language,
265 &locale_resources,
266 &locales,
267 )?;
268 Ok(Self {
269 selected_language: State::create(selected_language),
270 fallback_language: State::create(fallback_language),
271 locale_resources: State::create(locale_resources),
272 locales: State::create(locales),
273 active_bundle: State::create(bundle),
274 })
275 }
276
277 pub fn try_translate_with_args(
278 &self,
279 msg: &str,
280 args: Option<&FluentArgs>,
281 ) -> Result<String, Error> {
282 let (message_id, attribute_name) = Self::decompose_identifier(msg)?;
283
284 let bundle = self.active_bundle.read();
285
286 let message = bundle
287 .get_message(message_id)
288 .ok_or_else(|| Error::MessageIdNotFound(message_id.into()))?;
289
290 let pattern = if let Some(attribute_name) = attribute_name {
291 let attribute = message
292 .get_attribute(attribute_name)
293 .ok_or_else(|| Error::AttributeIdNotFound(msg.to_string()))?;
294 attribute.value()
295 } else {
296 message
297 .value()
298 .ok_or_else(|| Error::MessagePatternNotFound(message_id.into()))?
299 };
300
301 let mut errors = vec![];
302 let translation = bundle
303 .format_pattern(pattern, args, &mut errors)
304 .to_string();
305
306 (errors.is_empty())
307 .then_some(translation)
308 .ok_or_else(|| Error::FluentErrorsDetected(format!("{errors:#?}")))
309 }
310
311 pub fn decompose_identifier(msg: &str) -> Result<(&str, Option<&str>), Error> {
312 let parts: Vec<&str> = msg.split('.').collect();
313 match parts.as_slice() {
314 [message_id] => Ok((message_id, None)),
315 [message_id, attribute_name] => Ok((message_id, Some(attribute_name))),
316 _ => Err(Error::InvalidMessageId(msg.to_string())),
317 }
318 }
319
320 pub fn translate_with_args(&self, msg: &str, args: Option<&FluentArgs>) -> String {
321 let result = self.try_translate_with_args(msg, args);
322 match result {
323 Ok(translation) => translation,
324 Err(err) => panic!("Failed to translate {msg}: {err}"),
325 }
326 }
327
328 #[inline]
329 pub fn try_translate(&self, msg: &str) -> Result<String, Error> {
330 self.try_translate_with_args(msg, None)
331 }
332
333 pub fn translate(&self, msg: &str) -> String {
334 let result = self.try_translate(msg);
335 match result {
336 Ok(translation) => translation,
337 Err(err) => panic!("Failed to translate {msg}: {err}"),
338 }
339 }
340
341 #[inline]
343 pub fn language(&self) -> LanguageIdentifier {
344 self.selected_language.read().clone()
345 }
346
347 pub fn fallback_language(&self) -> Option<LanguageIdentifier> {
349 self.fallback_language.read().clone()
350 }
351
352 pub fn try_set_language(&mut self, id: LanguageIdentifier) -> Result<(), Error> {
354 *self.selected_language.write() = id;
355 self.try_update_active_bundle()
356 }
357
358 pub fn set_language(&mut self, id: LanguageIdentifier) {
360 let id_name = id.to_string();
361 let result = self.try_set_language(id);
362 match result {
363 Ok(()) => (),
364 Err(err) => panic!("cannot set language {id_name}: {err}"),
365 }
366 }
367
368 pub fn try_set_fallback_language(&mut self, id: LanguageIdentifier) -> Result<(), Error> {
370 self.locales
371 .read()
372 .get(&id)
373 .ok_or_else(|| Error::FallbackMustHaveLocale(id.to_string()))?;
374
375 *self.fallback_language.write() = Some(id);
376 self.try_update_active_bundle()
377 }
378
379 pub fn set_fallback_language(&mut self, id: LanguageIdentifier) {
381 let id_name = id.to_string();
382 let result = self.try_set_fallback_language(id);
383 match result {
384 Ok(()) => (),
385 Err(err) => panic!("cannot set fallback language {id_name}: {err}"),
386 }
387 }
388
389 fn try_update_active_bundle(&mut self) -> Result<(), Error> {
390 let bundle = try_create_bundle(
391 &self.selected_language.peek(),
392 &self.fallback_language.peek(),
393 &self.locale_resources.peek(),
394 &self.locales.peek(),
395 )?;
396
397 self.active_bundle.set(bundle);
398 Ok(())
399 }
400}
401
402fn try_create_bundle(
403 selected_language: &LanguageIdentifier,
404 fallback_language: &Option<LanguageIdentifier>,
405 locale_resources: &[LocaleResource],
406 locales: &HashMap<LanguageIdentifier, usize>,
407) -> Result<FluentBundle<FluentResource>, Error> {
408 let add_resource = move |bundle: &mut FluentBundle<FluentResource>,
409 langid: &LanguageIdentifier,
410 locale_resources: &[LocaleResource]| {
411 if let Some(&i) = locales.get(langid) {
412 let resource = &locale_resources[i];
413 let resource =
414 FluentResource::try_new(resource.try_to_resource_string()?).map_err(|e| {
415 Error::FluentErrorsDetected(format!("resource langid: {langid}\n{e:#?}"))
416 })?;
417 bundle.add_resource_overriding(resource);
418 };
419 Ok(())
420 };
421
422 let mut bundle = FluentBundle::new(vec![selected_language.clone()]);
423 if let Some(fallback_language) = fallback_language {
424 add_resource(&mut bundle, fallback_language, locale_resources)?;
425 }
426
427 let (language, script, region, variants) = selected_language.clone().into_parts();
428 let variants_lang = LanguageIdentifier::from_parts(language, script, region, &variants);
429 let region_lang = LanguageIdentifier::from_parts(language, script, region, &[]);
430 let script_lang = LanguageIdentifier::from_parts(language, script, None, &[]);
431 let language_lang = LanguageIdentifier::from_parts(language, None, None, &[]);
432
433 add_resource(&mut bundle, &language_lang, locale_resources)?;
434 add_resource(&mut bundle, &script_lang, locale_resources)?;
435 add_resource(&mut bundle, ®ion_lang, locale_resources)?;
436 add_resource(&mut bundle, &variants_lang, locale_resources)?;
437
438 Ok(bundle)
447}