bevy_intl/lib.rs
1#![allow(dead_code)]
2#![doc = include_str!("../README.md")]
3
4//! # bevy-intl
5//!
6//! A comprehensive internationalization (i18n) plugin for [Bevy](https://bevyengine.org/) that provides:
7//!
8//! - **WASM Compatible**: Automatic translation bundling for web deployment
9//! - **Flexible Loading**: Filesystem (desktop) or bundled files (WASM)
10//! - **Feature Flag**: `bundle-only` to force bundled translations on any platform
11//! - **Advanced Plurals**: Support for complex plural rules (ICU-compliant)
12//! - **Gender Support**: Gendered translations
13//! - **Placeholders**: Dynamic text replacement
14//! - **Fallback System**: Automatic fallback to default language
15//!
16//! ## Quick Start
17//!
18//! ```rust
19//! use bevy::prelude::*;
20//! use bevy_intl::I18nPlugin;
21//!
22//! fn main() {
23//! App::new()
24//! .add_plugins(DefaultPlugins)
25//! .add_plugins(I18nPlugin::default())
26//! .add_systems(Startup, setup_ui)
27//! .run();
28//! }
29//!
30//! fn setup_ui(mut commands: Commands, i18n: Res<bevy_intl::I18n>) {
31//! let text = i18n.translation("ui");
32//!
33//! commands.spawn((
34//! Text::new(text.t("welcome")),
35//! Node::default(),
36//! ));
37//! }
38//! ```
39//!
40//! ## Features
41//!
42//! ### Translation Loading
43//! - **Desktop**: Loads from `messages/` folder at runtime
44//! - **WASM**: Uses bundled translations (compiled at build time)
45//! - **Bundle-only**: Force bundled mode with `features = ["bundle-only"]`
46//!
47//! ### Advanced Plural Support
48//! Supports multiple plural forms with fallback priority:
49//! 1. Exact counts: `"0"`, `"1"`, `"2"`, etc.
50//! 2. ICU categories: `"zero"`, `"one"`, `"two"`, `"few"`, `"many"`
51//! 3. Basic fallback: `"one"` vs `"other"`
52//!
53//! Perfect for complex languages like Polish, Russian, and Arabic.
54
55use bevy::prelude::*;
56
57mod locales;
58
59use serde::Deserialize;
60use std::collections::{ HashMap };
61use serde_json::Value;
62use locales::LOCALES;
63use regex::Regex;
64use once_cell::sync::Lazy;
65
66/// Configuration for the I18n plugin.
67///
68/// Controls how translations are loaded and which languages to use.
69///
70/// # Example
71///
72/// ```rust
73/// use bevy_intl::I18nConfig;
74///
75/// let config = I18nConfig {
76/// use_bundled_translations: false,
77/// messages_folder: "locales".to_string(),
78/// default_lang: "fr".to_string(),
79/// fallback_lang: "en".to_string(),
80/// };
81/// ```
82#[derive(Debug, Clone, Resource)]
83pub struct I18nConfig {
84 /// Whether to use bundled translations (true) or filesystem loading (false).
85 /// Automatically set to `true` for WASM targets or when `bundle-only` feature is enabled.
86 pub use_bundled_translations: bool,
87 /// Path to the messages folder containing translation files.
88 /// Default: "messages"
89 pub messages_folder: String,
90 /// Default language code to use.
91 /// Default: "en"
92 pub default_lang: String,
93 /// Fallback language code when a translation is missing.
94 /// Default: "en"
95 pub fallback_lang: String,
96}
97
98impl Default for I18nConfig {
99 fn default() -> Self {
100 Self {
101 use_bundled_translations: cfg!(target_arch = "wasm32") || cfg!(feature = "bundle-only"),
102 messages_folder: "messages".to_string(),
103 default_lang: "en".to_string(),
104 fallback_lang: "en".to_string(),
105 }
106 }
107}
108
109// ---------- Bevy Plugin ----------
110
111/// Main plugin for Bevy internationalization.
112///
113/// Handles language switching, loading translation files, and providing
114/// `I18n` resource for accessing localized strings.
115///
116/// # Example
117///
118/// ```rust
119/// use bevy::prelude::*;
120/// use bevy_intl::{I18nPlugin, I18nConfig};
121///
122/// // Default configuration
123/// App::new().add_plugins(I18nPlugin::default());
124///
125/// // Custom configuration
126/// App::new().add_plugins(I18nPlugin::with_config(I18nConfig {
127/// default_lang: "fr".to_string(),
128/// fallback_lang: "en".to_string(),
129/// ..Default::default()
130/// }));
131/// ```
132#[derive(Default)]
133pub struct I18nPlugin {
134 /// Configuration for the plugin
135 pub config: I18nConfig,
136}
137
138impl I18nPlugin {
139 pub fn new() -> Self {
140 Self::default()
141 }
142
143 pub fn with_config(config: I18nConfig) -> Self {
144 Self { config }
145 }
146}
147
148impl Plugin for I18nPlugin {
149 fn build(&self, app: &mut App) {
150 app.insert_resource(self.config.clone()).init_resource::<I18n>();
151 }
152}
153
154/// Represents a value in a translation file.
155///
156/// Can be either a simple text string or a nested map for plurals/genders.
157///
158/// # Examples
159///
160/// Simple text:
161/// ```json
162/// "greeting": "Hello"
163/// ```
164///
165/// Nested map for plurals:
166/// ```json
167/// "items": {
168/// "one": "One item",
169/// "many": "{{count}} items"
170/// }
171/// ```
172#[derive(Debug, Deserialize, Clone)]
173#[serde(untagged)]
174pub enum SectionValue {
175 /// A simple text value
176 Text(String),
177 /// A nested map of key-value pairs (for plurals, genders, etc.)
178 Map(HashMap<String, String>),
179}
180
181/// A mapping of translation keys to their values within a file.
182type SectionMap = HashMap<String, SectionValue>;
183/// A mapping of file names to their section maps.
184type FileMap = HashMap<String, SectionMap>;
185/// A mapping of language codes to file maps.
186type LangMap = HashMap<String, FileMap>;
187
188/// Contains all translations loaded from filesystem or bundled data.
189///
190/// Organized as: `languages -> files -> keys -> values`
191#[derive(Debug, Deserialize)]
192pub struct Translations {
193 /// Map of language codes to their translation data
194 pub langs: LangMap,
195}
196
197/// Main resource for accessing translations in Bevy systems.
198///
199/// Provides methods to load translation files, get translated text,
200/// and manage current language settings.
201///
202/// # Example
203///
204/// ```rust
205/// use bevy::prelude::*;
206/// use bevy_intl::I18n;
207///
208/// fn my_system(i18n: Res<I18n>) {
209/// let translations = i18n.translation("ui");
210/// let text = translations.t("welcome_message");
211/// println!("{}", text);
212/// }
213/// ```
214#[derive(Resource)]
215pub struct I18n {
216 /// All loaded translations
217 translations: Translations,
218 /// Currently active language
219 current_lang: String,
220 /// List of available languages
221 locale_folders_list: Vec<String>,
222 /// Fallback language when translation is missing
223 fallback_lang: String,
224}
225
226impl FromWorld for I18n {
227 fn from_world(world: &mut World) -> Self {
228 let config = world.get_resource::<I18nConfig>().cloned().unwrap_or_default();
229
230 let (translations, locale_folders_list) = if config.use_bundled_translations {
231 load_bundled_translations()
232 } else {
233 load_filesystem_translations(&config.messages_folder)
234 };
235
236 Self {
237 current_lang: config.default_lang,
238 fallback_lang: config.fallback_lang,
239 translations,
240 locale_folders_list,
241 }
242 }
243}
244
245// ---------- Loaders ----------
246
247// Chargement depuis le système de fichiers (mode dev/desktop)
248#[cfg(not(target_arch = "wasm32"))]
249fn load_filesystem_translations(messages_folder: &str) -> (Translations, Vec<String>) {
250 match load_translation_from_fs(messages_folder) {
251 Ok(langs) => {
252 let locale_list = langs.keys().cloned().collect();
253 (Translations { langs }, locale_list)
254 }
255 Err(e) => {
256 eprintln!("⚠️ Failed to load translations from '{}': {}", messages_folder, e);
257 create_error_translations()
258 }
259 }
260}
261
262#[cfg(target_arch = "wasm32")]
263fn load_filesystem_translations(_messages_folder: &str) -> (Translations, Vec<String>) {
264 eprintln!("⚠️ Filesystem loading not available on WASM, using bundled translations");
265 load_bundled_translations()
266}
267
268// Chargement depuis les traductions intégrées (bundle au build time)
269fn load_bundled_translations() -> (Translations, Vec<String>) {
270 match load_bundled_data() {
271 Ok(langs) => {
272 let locale_list = langs.keys().cloned().collect();
273 (Translations { langs }, locale_list)
274 }
275 Err(e) => {
276 eprintln!("⚠️ Failed to load bundled translations: {}", e);
277 create_error_translations()
278 }
279 }
280}
281
282// Charge les données bundlées (générées par build.rs)
283fn load_bundled_data() -> Result<LangMap, Box<dyn std::error::Error>> {
284 const BUNDLED_TRANSLATIONS: &str = include_str!(
285 concat!(env!("OUT_DIR"), "/all_translations.json")
286 );
287 let value: Value = serde_json::from_str(BUNDLED_TRANSLATIONS)?;
288 parse_translation_value(value)
289}
290
291// Parse une Value JSON vers LangMap
292fn parse_translation_value(value: Value) -> Result<LangMap, Box<dyn std::error::Error>> {
293 let mut lang_map = HashMap::new();
294
295 if let Some(langs_obj) = value.as_object() {
296 for (lang_code, files_value) in langs_obj {
297 let mut file_map = HashMap::new();
298
299 if let Some(files_obj) = files_value.as_object() {
300 for (file_name, sections_value) in files_obj {
301 let mut section_map = HashMap::new();
302
303 if let Some(sections_obj) = sections_value.as_object() {
304 for (key, val) in sections_obj {
305 let section_value = if let Some(text) = val.as_str() {
306 SectionValue::Text(text.to_string())
307 } else if let Some(nested) = val.as_object() {
308 let mut nested_map = HashMap::new();
309 for (nested_key, nested_val) in nested {
310 if let Some(nested_str) = nested_val.as_str() {
311 nested_map.insert(
312 nested_key.clone(),
313 nested_str.to_string()
314 );
315 }
316 }
317 SectionValue::Map(nested_map)
318 } else {
319 continue;
320 };
321 section_map.insert(key.clone(), section_value);
322 }
323 }
324 file_map.insert(file_name.clone(), section_map);
325 }
326 }
327 lang_map.insert(lang_code.clone(), file_map);
328 }
329 }
330
331 Ok(lang_map)
332}
333
334// Version système de fichiers
335#[cfg(not(target_arch = "wasm32"))]
336fn load_translation_from_fs(messages_folder: &str) -> std::io::Result<LangMap> {
337 use std::fs;
338 use std::path::Path;
339
340 let message_dir = Path::new(messages_folder);
341
342 if !message_dir.exists() {
343 return Err(
344 std::io::Error::new(
345 std::io::ErrorKind::NotFound,
346 format!("{} folder not found", messages_folder)
347 )
348 );
349 }
350
351 let mut lang_map = HashMap::new();
352
353 for folder_entry in fs::read_dir(message_dir)? {
354 let folder = folder_entry?;
355 let lang_code = folder.file_name().to_string_lossy().to_string();
356 let mut file_map = HashMap::new();
357
358 for file_entry in fs::read_dir(folder.path())? {
359 let file = file_entry?;
360 let path = file.path();
361
362 if path.is_file() && path.extension().and_then(|e| e.to_str()) == Some("json") {
363 let file_name = path
364 .file_stem()
365 .and_then(|s| s.to_str())
366 .unwrap_or("unknown")
367 .to_string();
368
369 let content = fs::read_to_string(&path)?;
370 let json: Value = serde_json
371 ::from_str(&content)
372 .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
373
374 let mut section_map = HashMap::new();
375
376 if let Some(obj) = json.as_object() {
377 for (key, value) in obj {
378 let section_value = if let Some(text) = value.as_str() {
379 SectionValue::Text(text.to_string())
380 } else if let Some(nested) = value.as_object() {
381 let mut nested_map = HashMap::new();
382 for (nested_key, nested_val) in nested {
383 if let Some(nested_str) = nested_val.as_str() {
384 nested_map.insert(nested_key.clone(), nested_str.to_string());
385 }
386 }
387 SectionValue::Map(nested_map)
388 } else {
389 continue;
390 };
391 section_map.insert(key.clone(), section_value);
392 }
393 }
394
395 file_map.insert(file_name, section_map);
396 }
397 }
398
399 lang_map.insert(lang_code, file_map);
400 }
401
402 Ok(lang_map)
403}
404
405// Traductions d'erreur par défaut
406fn create_error_translations() -> (Translations, Vec<String>) {
407 let mut section_map = HashMap::new();
408 section_map.insert("error".to_string(), SectionValue::Text("Translation Error".to_string()));
409
410 let mut file_map = HashMap::new();
411 file_map.insert("error".to_string(), section_map);
412
413 let mut lang_map = HashMap::new();
414 lang_map.insert("en".to_string(), file_map);
415
416 (Translations { langs: lang_map }, vec!["en".to_string()])
417}
418
419// ---------- API ----------
420
421/// Extension trait for `App` to easily manage languages.
422///
423/// Provides convenient methods to set current and fallback languages
424/// directly on the Bevy `App`.
425///
426/// # Example
427///
428/// ```rust
429/// use bevy::prelude::*;
430/// use bevy_intl::LanguageAppExt;
431///
432/// fn setup_language(mut app: ResMut<App>) {
433/// app.set_lang_i18n("fr");
434/// app.set_fallback_lang("en");
435/// }
436/// ```
437pub trait LanguageAppExt {
438 /// Sets the current language for translations.
439 ///
440 /// Warns if the language is not available in loaded translations.
441 fn set_lang_i18n(&mut self, locale: &str);
442 /// Sets the fallback language for translations.
443 ///
444 /// Warns if the fallback language is not available in loaded translations.
445 fn set_fallback_lang(&mut self, locale: &str);
446}
447
448impl LanguageAppExt for App {
449 fn set_lang_i18n(&mut self, locale: &str) {
450 if let Some(mut i18n) = self.world_mut().get_resource_mut::<I18n>() {
451 if !i18n.locale_folders_list.contains(&locale.to_string()) {
452 warn!("Locale '{}' not found in available translations", locale);
453 return;
454 }
455 i18n.current_lang = locale.to_string();
456 }
457 }
458
459 fn set_fallback_lang(&mut self, locale: &str) {
460 if let Some(mut i18n) = self.world_mut().get_resource_mut::<I18n>() {
461 if !i18n.locale_folders_list.contains(&locale.to_string()) {
462 warn!("Fallback locale '{}' not found in available translations", locale);
463 return;
464 }
465 i18n.fallback_lang = locale.to_string();
466 }
467 }
468}
469
470// ---------- Translation Handling ----------
471
472/// Represents translations for a single file.
473///
474/// Provides methods to access translated text with support for
475/// placeholders, plurals, and gendered translations.
476///
477/// # Example
478///
479/// ```rust
480/// use bevy::prelude::*;
481/// use bevy_intl::I18n;
482///
483/// fn display_text(i18n: Res<I18n>) {
484/// let t = i18n.translation("ui");
485///
486/// // Simple translation
487/// let greeting = t.t("hello");
488///
489/// // With placeholder
490/// let welcome = t.t_with_arg("welcome", &[&"John"]);
491///
492/// // Plural form
493/// let items = t.t_with_plural("item_count", 5);
494///
495/// // Gendered translation
496/// let title = t.t_with_gender("title", "male");
497/// }
498/// ```
499pub struct I18nPartial {
500 /// Translations for the current language
501 file_traductions: SectionMap,
502 /// Fallback translations when current language is missing a key
503 fallback_traduction: SectionMap,
504}
505
506impl I18n {
507 /// Loads translations for a specific file.
508 ///
509 /// Returns an `I18nPartial` that provides access to all translation
510 /// methods for that file.
511 ///
512 /// # Arguments
513 ///
514 /// * `translation_file` - Name of the translation file (without .json extension)
515 ///
516 /// # Example
517 ///
518 /// ```rust
519 /// use bevy::prelude::*;
520 /// use bevy_intl::I18n;
521 ///
522 /// fn my_system(i18n: Res<I18n>) {
523 /// let ui_translations = i18n.translation("ui");
524 /// let menu_translations = i18n.translation("menu");
525 /// }
526 /// ```
527 pub fn translation(&self, translation_file: &str) -> I18nPartial {
528 let error_map = {
529 let mut map = HashMap::new();
530 map.insert(
531 "error".to_string(),
532 SectionValue::Text("Translation not found".to_string())
533 );
534 map
535 };
536
537 // Traduction courante
538 let current_file = self.translations.langs
539 .get(&self.current_lang)
540 .and_then(|lang| lang.get(translation_file))
541 .cloned()
542 .unwrap_or_else(|| error_map.clone());
543
544 // Traduction de fallback
545 let fallback_file = self.translations.langs
546 .get(&self.fallback_lang)
547 .and_then(|lang| lang.get(translation_file))
548 .cloned()
549 .unwrap_or(error_map);
550
551 I18nPartial {
552 file_traductions: current_file,
553 fallback_traduction: fallback_file,
554 }
555 }
556
557 /// Sets the current language.
558 ///
559 /// # Arguments
560 ///
561 /// * `locale` - Language code (e.g., "en", "fr", "es")
562 ///
563 /// # Example
564 ///
565 /// ```rust
566 /// use bevy::prelude::*;
567 /// use bevy_intl::I18n;
568 ///
569 /// fn change_language(mut i18n: ResMut<I18n>) {
570 /// i18n.set_lang("fr");
571 /// }
572 /// ```
573 pub fn set_lang(&mut self, locale: &str) {
574 if !self.locale_folders_list.contains(&locale.to_string()) {
575 warn!("Locale '{}' not available", locale);
576 return;
577 }
578 self.current_lang = locale.to_string();
579 }
580
581 /// Gets the current language code.
582 ///
583 /// # Returns
584 ///
585 /// The current language code as a string slice.
586 ///
587 /// # Example
588 ///
589 /// ```rust
590 /// use bevy::prelude::*;
591 /// use bevy_intl::I18n;
592 ///
593 /// fn show_current_language(i18n: Res<I18n>) {
594 /// println!("Current language: {}", i18n.get_lang());
595 /// }
596 /// ```
597 pub fn get_lang(&self) -> &str {
598 &self.current_lang
599 }
600
601 /// Gets a list of all available languages.
602 ///
603 /// # Returns
604 ///
605 /// A slice of available language codes.
606 ///
607 /// # Example
608 ///
609 /// ```rust
610 /// use bevy::prelude::*;
611 /// use bevy_intl::I18n;
612 ///
613 /// fn list_languages(i18n: Res<I18n>) {
614 /// for lang in i18n.available_languages() {
615 /// println!("Available: {}", lang);
616 /// }
617 /// }
618 /// ```
619 pub fn available_languages(&self) -> &[String] {
620 &self.locale_folders_list
621 }
622}
623
624// ---------- Text helpers ----------
625static ARG_RE: Lazy<Regex> = Lazy::new(|| Regex::new(r"\{\{(\w*)\}\}").unwrap());
626
627impl I18nPartial {
628 /// Gets a translated string for the given key.
629 ///
630 /// Falls back to the fallback language if the key is not found
631 /// in the current language.
632 ///
633 /// # Arguments
634 ///
635 /// * `key` - Translation key to look up
636 ///
637 /// # Returns
638 ///
639 /// The translated string, or "Missing translation" if not found.
640 ///
641 /// # Example
642 ///
643 /// ```rust
644 /// let text = i18n.translation("ui").t("hello");
645 /// ```
646 pub fn t(&self, key: &str) -> String {
647 self.get_text_value(key).unwrap_or_else(|| "Missing translation".to_string())
648 }
649
650 /// Gets a translated string with placeholder replacement.
651 ///
652 /// Replaces `{{}}` placeholders in the translation with the provided arguments.
653 ///
654 /// # Arguments
655 ///
656 /// * `key` - Translation key to look up
657 /// * `args` - Values to replace placeholders with
658 ///
659 /// # Returns
660 ///
661 /// The translated string with placeholders replaced.
662 ///
663 /// # Example
664 ///
665 /// ```rust
666 /// // JSON: "welcome": "Hello {{name}}!"
667 /// let text = i18n.translation("ui").t_with_arg("welcome", &[&"John"]);
668 /// // Result: "Hello John!"
669 /// ```
670 pub fn t_with_arg(&self, key: &str, args: &[&dyn ToString]) -> String {
671 let template = self.t(key);
672 self.replace_placeholders(&template, args)
673 }
674
675 /// Gets a pluralized translation based on count.
676 ///
677 /// Uses advanced plural rules with fallback priority:
678 /// 1. Exact count ("0", "1", "2", etc.)
679 /// 2. ICU categories ("zero", "one", "two", "few", "many")
680 /// 3. Basic fallback ("one" vs "other")
681 ///
682 /// # Arguments
683 ///
684 /// * `key` - Translation key to look up
685 /// * `count` - Number to determine plural form
686 ///
687 /// # Returns
688 ///
689 /// The translated string with count placeholder replaced.
690 ///
691 /// # Example
692 ///
693 /// ```rust
694 /// // JSON: "items": { "one": "One item", "many": "{{count}} items" }
695 /// let text = i18n.translation("ui").t_with_plural("items", 5);
696 /// // Result: "5 items"
697 /// ```
698 pub fn t_with_plural(&self, key: &str, count: usize) -> String {
699 // Try specific count first, then fallback to generic rules
700 let count_str = count.to_string();
701
702 // 1. Try exact count (e.g., "0", "1", "2", "3"...)
703 if let Some(template) = self.get_nested_value(key, &count_str) {
704 return self.replace_placeholders(&template, &[&count]);
705 }
706
707 // 2. Try standard plural categories
708 let plural_key = match count {
709 0 => "zero", // Changed from "none" to match ICU standards
710 1 => "one",
711 2 => "two",
712 3..=10 => "few", // For languages like Polish, Russian
713 _ => "many",
714 };
715
716 if let Some(template) = self.get_nested_value(key, plural_key) {
717 return self.replace_placeholders(&template, &[&count]);
718 }
719
720 // 3. Fallback to basic English rules
721 let basic_key = if count == 1 { "one" } else { "other" };
722 if let Some(template) = self.get_nested_value(key, basic_key) {
723 return self.replace_placeholders(&template, &[&count]);
724 }
725
726 // 4. Last resort fallbacks
727 if let Some(template) = self.get_nested_value(key, "many") {
728 return self.replace_placeholders(&template, &[&count]);
729 }
730
731 "Missing plural translation".to_string()
732 }
733
734 /// Gets a gendered translation.
735 ///
736 /// # Arguments
737 ///
738 /// * `key` - Translation key to look up
739 /// * `gender` - Gender key (e.g., "male", "female", "neutral")
740 ///
741 /// # Returns
742 ///
743 /// The translated string for the specified gender.
744 ///
745 /// # Example
746 ///
747 /// ```rust
748 /// // JSON: "title": { "male": "Mr.", "female": "Ms." }
749 /// let text = i18n.translation("ui").t_with_gender("title", "female");
750 /// // Result: "Ms."
751 /// ```
752 pub fn t_with_gender(&self, key: &str, gender: &str) -> String {
753 self.get_nested_value(key, gender).unwrap_or_else(||
754 "Missing gender translation".to_string()
755 )
756 }
757
758 /// Gets a gendered translation with placeholder replacement.
759 ///
760 /// Combines gender selection and argument replacement.
761 ///
762 /// # Arguments
763 ///
764 /// * `key` - Translation key to look up
765 /// * `gender` - Gender key
766 /// * `args` - Values to replace placeholders with
767 ///
768 /// # Returns
769 ///
770 /// The translated string for the gender with placeholders replaced.
771 ///
772 /// # Example
773 ///
774 /// ```rust
775 /// // JSON: "greeting": { "male": "Hello Mr. {{name}}", "female": "Hello Ms. {{name}}" }
776 /// let text = i18n.translation("ui").t_with_gender_and_arg("greeting", "male", &[&"Smith"]);
777 /// // Result: "Hello Mr. Smith"
778 /// ```
779 pub fn t_with_gender_and_arg(&self, key: &str, gender: &str, args: &[&dyn ToString]) -> String {
780 let template = self.t_with_gender(key, gender);
781 self.replace_placeholders(&template, args)
782 }
783
784 // Méthodes utilitaires privées
785 fn get_text_value(&self, key: &str) -> Option<String> {
786 self.file_traductions
787 .get(key)
788 .and_then(|v| if let SectionValue::Text(s) = v { Some(s.clone()) } else { None })
789 .or_else(|| {
790 self.fallback_traduction
791 .get(key)
792 .and_then(|v| (
793 if let SectionValue::Text(s) = v {
794 Some(s.clone())
795 } else {
796 None
797 }
798 ))
799 })
800 }
801
802 fn get_nested_value(&self, key: &str, nested_key: &str) -> Option<String> {
803 self.file_traductions
804 .get(key)
805 .and_then(|v| (
806 if let SectionValue::Map(m) = v {
807 m.get(nested_key).cloned()
808 } else {
809 None
810 }
811 ))
812 .or_else(|| {
813 self.fallback_traduction
814 .get(key)
815 .and_then(|v| (
816 if let SectionValue::Map(m) = v {
817 m.get(nested_key).cloned()
818 } else {
819 None
820 }
821 ))
822 })
823 }
824
825 fn replace_placeholders(&self, template: &str, args: &[&dyn ToString]) -> String {
826 let parts: Vec<&str> = ARG_RE.split(template).collect();
827 let mut result = String::new();
828
829 for (i, part) in parts.iter().enumerate() {
830 result.push_str(part);
831 if i < args.len() {
832 result.push_str(&args[i].to_string());
833 }
834 }
835
836 result
837 }
838}
839
840// ---------- Utils ----------
841
842/// Checks if a locale string exists as an international standard.
843///
844/// Uses the built-in LOCALES list to validate locale codes against
845/// international standards (ISO 639-1, ISO 3166-1, etc.).
846fn locale_exists_as_international_standard(locale: &str) -> bool {
847 LOCALES.binary_search(&locale).is_ok()
848}