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// Loading from filesystem (dev/desktop mode)
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// Loading from bundled translations (bundled at build time)
269fn load_bundled_translations() -> (Translations, Vec<String>) {
270    match load_bundled_data() {
271        Ok(langs) => {
272            if langs.is_empty() {
273                // Bundled translations are empty, fall back to filesystem
274                load_filesystem_translations("messages")
275            } else {
276                let locale_list = langs.keys().cloned().collect();
277                (Translations { langs }, locale_list)
278            }
279        }
280        Err(e) => {
281            eprintln!("⚠️ Failed to load bundled translations: {}", e);
282            create_error_translations()
283        }
284    }
285}
286
287// Load bundled data (generated by build.rs)
288fn load_bundled_data() -> Result<LangMap, Box<dyn std::error::Error>> {
289    const BUNDLED_TRANSLATIONS: &str = include_str!(
290        concat!(env!("OUT_DIR"), "/all_translations.json")
291    );
292    
293    // Check if bundled translations are empty (happens when bevy-intl is built standalone)
294    let value: Value = serde_json::from_str(BUNDLED_TRANSLATIONS)?;
295    if value.as_object().map_or(true, |obj| obj.is_empty()) {
296        // Return empty translation map - will fall back to filesystem loading
297        return Ok(HashMap::new());
298    }
299    
300    parse_translation_value(value)
301}
302
303// Parse a JSON Value to LangMap
304fn parse_translation_value(value: Value) -> Result<LangMap, Box<dyn std::error::Error>> {
305    let mut lang_map = HashMap::new();
306
307    if let Some(langs_obj) = value.as_object() {
308        for (lang_code, files_value) in langs_obj {
309            let mut file_map = HashMap::new();
310
311            if let Some(files_obj) = files_value.as_object() {
312                for (file_name, sections_value) in files_obj {
313                    let mut section_map = HashMap::new();
314
315                    if let Some(sections_obj) = sections_value.as_object() {
316                        for (key, val) in sections_obj {
317                            let section_value = if let Some(text) = val.as_str() {
318                                SectionValue::Text(text.to_string())
319                            } else if let Some(nested) = val.as_object() {
320                                let mut nested_map = HashMap::new();
321                                for (nested_key, nested_val) in nested {
322                                    if let Some(nested_str) = nested_val.as_str() {
323                                        nested_map.insert(
324                                            nested_key.clone(),
325                                            nested_str.to_string()
326                                        );
327                                    }
328                                }
329                                SectionValue::Map(nested_map)
330                            } else {
331                                continue;
332                            };
333                            section_map.insert(key.clone(), section_value);
334                        }
335                    }
336                    file_map.insert(file_name.clone(), section_map);
337                }
338            }
339            lang_map.insert(lang_code.clone(), file_map);
340        }
341    }
342
343    Ok(lang_map)
344}
345
346// Filesystem version
347#[cfg(not(target_arch = "wasm32"))]
348fn load_translation_from_fs(messages_folder: &str) -> std::io::Result<LangMap> {
349    use std::fs;
350    use std::path::Path;
351
352    let message_dir = Path::new(messages_folder);
353
354    if !message_dir.exists() {
355        return Err(
356            std::io::Error::new(
357                std::io::ErrorKind::NotFound,
358                format!("{} folder not found", messages_folder)
359            )
360        );
361    }
362
363    let mut lang_map = HashMap::new();
364
365    for folder_entry in fs::read_dir(message_dir)? {
366        let folder = folder_entry?;
367        let lang_code = folder.file_name().to_string_lossy().to_string();
368        let mut file_map = HashMap::new();
369
370        for file_entry in fs::read_dir(folder.path())? {
371            let file = file_entry?;
372            let path = file.path();
373
374            if path.is_file() && path.extension().and_then(|e| e.to_str()) == Some("json") {
375                let file_name = path
376                    .file_stem()
377                    .and_then(|s| s.to_str())
378                    .unwrap_or("unknown")
379                    .to_string();
380
381                let content = fs::read_to_string(&path)?;
382                let json: Value = serde_json
383                    ::from_str(&content)
384                    .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
385
386                let mut section_map = HashMap::new();
387
388                if let Some(obj) = json.as_object() {
389                    for (key, value) in obj {
390                        let section_value = if let Some(text) = value.as_str() {
391                            SectionValue::Text(text.to_string())
392                        } else if let Some(nested) = value.as_object() {
393                            let mut nested_map = HashMap::new();
394                            for (nested_key, nested_val) in nested {
395                                if let Some(nested_str) = nested_val.as_str() {
396                                    nested_map.insert(nested_key.clone(), nested_str.to_string());
397                                }
398                            }
399                            SectionValue::Map(nested_map)
400                        } else {
401                            continue;
402                        };
403                        section_map.insert(key.clone(), section_value);
404                    }
405                }
406
407                file_map.insert(file_name, section_map);
408            }
409        }
410
411        lang_map.insert(lang_code, file_map);
412    }
413
414    Ok(lang_map)
415}
416
417// Default error translations
418fn create_error_translations() -> (Translations, Vec<String>) {
419    let mut section_map = HashMap::new();
420    section_map.insert("error".to_string(), SectionValue::Text("Translation Error".to_string()));
421
422    let mut file_map = HashMap::new();
423    file_map.insert("error".to_string(), section_map);
424
425    let mut lang_map = HashMap::new();
426    lang_map.insert("en".to_string(), file_map);
427
428    (Translations { langs: lang_map }, vec!["en".to_string()])
429}
430
431// ---------- API ----------
432
433/// Extension trait for `App` to easily manage languages.
434/// 
435/// Provides convenient methods to set current and fallback languages
436/// directly on the Bevy `App`.
437/// 
438/// # Example
439/// 
440/// ```rust
441/// use bevy::prelude::*;
442/// use bevy_intl::LanguageAppExt;
443/// 
444/// fn setup_language(mut app: ResMut<App>) {
445///     app.set_lang_i18n("fr");
446///     app.set_fallback_lang("en");
447/// }
448/// ```
449pub trait LanguageAppExt {
450    /// Sets the current language for translations.
451    /// 
452    /// Warns if the language is not available in loaded translations.
453    fn set_lang_i18n(&mut self, locale: &str);
454    /// Sets the fallback language for translations.
455    /// 
456    /// Warns if the fallback language is not available in loaded translations.
457    fn set_fallback_lang(&mut self, locale: &str);
458}
459
460impl LanguageAppExt for App {
461    fn set_lang_i18n(&mut self, locale: &str) {
462        if let Some(mut i18n) = self.world_mut().get_resource_mut::<I18n>() {
463            if !i18n.locale_folders_list.contains(&locale.to_string()) {
464                warn!("Locale '{}' not found in available translations", locale);
465                return;
466            }
467            i18n.current_lang = locale.to_string();
468        }
469    }
470
471    fn set_fallback_lang(&mut self, locale: &str) {
472        if let Some(mut i18n) = self.world_mut().get_resource_mut::<I18n>() {
473            if !i18n.locale_folders_list.contains(&locale.to_string()) {
474                warn!("Fallback locale '{}' not found in available translations", locale);
475                return;
476            }
477            i18n.fallback_lang = locale.to_string();
478        }
479    }
480}
481
482// ---------- Translation Handling ----------
483
484/// Represents translations for a single file.
485/// 
486/// Provides methods to access translated text with support for
487/// placeholders, plurals, and gendered translations.
488/// 
489/// # Example
490/// 
491/// ```rust
492/// use bevy::prelude::*;
493/// use bevy_intl::I18n;
494/// 
495/// fn display_text(i18n: Res<I18n>) {
496///     let t = i18n.translation("ui");
497///     
498///     // Simple translation
499///     let greeting = t.t("hello");
500///     
501///     // With placeholder
502///     let welcome = t.t_with_arg("welcome", &[&"John"]);
503///     
504///     // Plural form
505///     let items = t.t_with_plural("item_count", 5);
506///     
507///     // Gendered translation
508///     let title = t.t_with_gender("title", "male");
509/// }
510/// ```
511pub struct I18nPartial {
512    /// Translations for the current language
513    file_traductions: SectionMap,
514    /// Fallback translations when current language is missing a key
515    fallback_traduction: SectionMap,
516}
517
518impl I18n {
519    /// Loads translations for a specific file.
520    /// 
521    /// Returns an `I18nPartial` that provides access to all translation
522    /// methods for that file.
523    /// 
524    /// # Arguments
525    /// 
526    /// * `translation_file` - Name of the translation file (without .json extension)
527    /// 
528    /// # Example
529    /// 
530    /// ```rust
531    /// use bevy::prelude::*;
532    /// use bevy_intl::I18n;
533    /// 
534    /// fn my_system(i18n: Res<I18n>) {
535    ///     let ui_translations = i18n.translation("ui");
536    ///     let menu_translations = i18n.translation("menu");
537    /// }
538    /// ```
539    pub fn translation(&self, translation_file: &str) -> I18nPartial {
540        let error_map = {
541            let mut map = HashMap::new();
542            map.insert(
543                "error".to_string(),
544                SectionValue::Text("Translation not found".to_string())
545            );
546            map
547        };
548
549        // Current translation
550        let current_file = self.translations.langs
551            .get(&self.current_lang)
552            .and_then(|lang| lang.get(translation_file))
553            .cloned()
554            .unwrap_or_else(|| error_map.clone());
555
556        // Fallback translation
557        let fallback_file = self.translations.langs
558            .get(&self.fallback_lang)
559            .and_then(|lang| lang.get(translation_file))
560            .cloned()
561            .unwrap_or(error_map);
562
563        I18nPartial {
564            file_traductions: current_file,
565            fallback_traduction: fallback_file,
566        }
567    }
568
569    /// Sets the current language.
570    /// 
571    /// # Arguments
572    /// 
573    /// * `locale` - Language code (e.g., "en", "fr", "es")
574    /// 
575    /// # Example
576    /// 
577    /// ```rust
578    /// use bevy::prelude::*;
579    /// use bevy_intl::I18n;
580    /// 
581    /// fn change_language(mut i18n: ResMut<I18n>) {
582    ///     i18n.set_lang("fr");
583    /// }
584    /// ```
585    pub fn set_lang(&mut self, locale: &str) {
586        if !self.locale_folders_list.contains(&locale.to_string()) {
587            warn!("Locale '{}' not available", locale);
588            return;
589        }
590        self.current_lang = locale.to_string();
591    }
592
593    /// Gets the current language code.
594    /// 
595    /// # Returns
596    /// 
597    /// The current language code as a string slice.
598    /// 
599    /// # Example
600    /// 
601    /// ```rust
602    /// use bevy::prelude::*;
603    /// use bevy_intl::I18n;
604    /// 
605    /// fn show_current_language(i18n: Res<I18n>) {
606    ///     println!("Current language: {}", i18n.get_lang());
607    /// }
608    /// ```
609    pub fn get_lang(&self) -> &str {
610        &self.current_lang
611    }
612
613    /// Gets a list of all available languages.
614    /// 
615    /// # Returns
616    /// 
617    /// A slice of available language codes.
618    /// 
619    /// # Example
620    /// 
621    /// ```rust
622    /// use bevy::prelude::*;
623    /// use bevy_intl::I18n;
624    /// 
625    /// fn list_languages(i18n: Res<I18n>) {
626    ///     for lang in i18n.available_languages() {
627    ///         println!("Available: {}", lang);
628    ///     }
629    /// }
630    /// ```
631    pub fn available_languages(&self) -> &[String] {
632        &self.locale_folders_list
633    }
634}
635
636// ---------- Text helpers ----------
637static ARG_RE: Lazy<Regex> = Lazy::new(|| Regex::new(r"\{\{(\w*)\}\}").unwrap());
638
639impl I18nPartial {
640    /// Gets a translated string for the given key.
641    /// 
642    /// Falls back to the fallback language if the key is not found
643    /// in the current language.
644    /// 
645    /// # Arguments
646    /// 
647    /// * `key` - Translation key to look up
648    /// 
649    /// # Returns
650    /// 
651    /// The translated string, or "Missing translation" if not found.
652    /// 
653    /// # Example
654    /// 
655    /// ```rust
656    /// let text = i18n.translation("ui").t("hello");
657    /// ```
658    pub fn t(&self, key: &str) -> String {
659        self.get_text_value(key).unwrap_or_else(|| "Missing translation".to_string())
660    }
661
662    /// Gets a translated string with placeholder replacement.
663    /// 
664    /// Replaces `{{}}` placeholders in the translation with the provided arguments.
665    /// 
666    /// # Arguments
667    /// 
668    /// * `key` - Translation key to look up
669    /// * `args` - Values to replace placeholders with
670    /// 
671    /// # Returns
672    /// 
673    /// The translated string with placeholders replaced.
674    /// 
675    /// # Example
676    /// 
677    /// ```rust
678    /// // JSON: "welcome": "Hello {{name}}!"
679    /// let text = i18n.translation("ui").t_with_arg("welcome", &[&"John"]);
680    /// // Result: "Hello John!"
681    /// ```
682    pub fn t_with_arg(&self, key: &str, args: &[&dyn ToString]) -> String {
683        let template = self.t(key);
684        self.replace_placeholders(&template, args)
685    }
686
687    /// Gets a pluralized translation based on count.
688    /// 
689    /// Uses advanced plural rules with fallback priority:
690    /// 1. Exact count ("0", "1", "2", etc.)
691    /// 2. ICU categories ("zero", "one", "two", "few", "many")
692    /// 3. Basic fallback ("one" vs "other")
693    /// 
694    /// # Arguments
695    /// 
696    /// * `key` - Translation key to look up
697    /// * `count` - Number to determine plural form
698    /// 
699    /// # Returns
700    /// 
701    /// The translated string with count placeholder replaced.
702    /// 
703    /// # Example
704    /// 
705    /// ```rust
706    /// // JSON: "items": { "one": "One item", "many": "{{count}} items" }
707    /// let text = i18n.translation("ui").t_with_plural("items", 5);
708    /// // Result: "5 items"
709    /// ```
710    pub fn t_with_plural(&self, key: &str, count: usize) -> String {
711        // Try specific count first, then fallback to generic rules
712        let count_str = count.to_string();
713        
714        // 1. Try exact count (e.g., "0", "1", "2", "3"...)
715        if let Some(template) = self.get_nested_value(key, &count_str) {
716            return self.replace_placeholders(&template, &[&count]);
717        }
718        
719        // 2. Try standard plural categories
720        let plural_key = match count {
721            0 => "zero",    // Changed from "none" to match ICU standards
722            1 => "one",
723            2 => "two",
724            3..=10 => "few",      // For languages like Polish, Russian
725            _ => "many",
726        };
727
728        if let Some(template) = self.get_nested_value(key, plural_key) {
729            return self.replace_placeholders(&template, &[&count]);
730        }
731        
732        // 3. Fallback to basic English rules
733        let basic_key = if count == 1 { "one" } else { "other" };
734        if let Some(template) = self.get_nested_value(key, basic_key) {
735            return self.replace_placeholders(&template, &[&count]);
736        }
737        
738        // 4. Last resort fallbacks
739        if let Some(template) = self.get_nested_value(key, "many") {
740            return self.replace_placeholders(&template, &[&count]);
741        }
742        
743        "Missing plural translation".to_string()
744    }
745
746    /// Gets a gendered translation.
747    /// 
748    /// # Arguments
749    /// 
750    /// * `key` - Translation key to look up
751    /// * `gender` - Gender key (e.g., "male", "female", "neutral")
752    /// 
753    /// # Returns
754    /// 
755    /// The translated string for the specified gender.
756    /// 
757    /// # Example
758    /// 
759    /// ```rust
760    /// // JSON: "title": { "male": "Mr.", "female": "Ms." }
761    /// let text = i18n.translation("ui").t_with_gender("title", "female");
762    /// // Result: "Ms."
763    /// ```
764    pub fn t_with_gender(&self, key: &str, gender: &str) -> String {
765        self.get_nested_value(key, gender).unwrap_or_else(||
766            "Missing gender translation".to_string()
767        )
768    }
769
770    /// Gets a gendered translation with placeholder replacement.
771    /// 
772    /// Combines gender selection and argument replacement.
773    /// 
774    /// # Arguments
775    /// 
776    /// * `key` - Translation key to look up
777    /// * `gender` - Gender key
778    /// * `args` - Values to replace placeholders with
779    /// 
780    /// # Returns
781    /// 
782    /// The translated string for the gender with placeholders replaced.
783    /// 
784    /// # Example
785    /// 
786    /// ```rust
787    /// // JSON: "greeting": { "male": "Hello Mr. {{name}}", "female": "Hello Ms. {{name}}" }
788    /// let text = i18n.translation("ui").t_with_gender_and_arg("greeting", "male", &[&"Smith"]);
789    /// // Result: "Hello Mr. Smith"
790    /// ```
791    pub fn t_with_gender_and_arg(&self, key: &str, gender: &str, args: &[&dyn ToString]) -> String {
792        let template = self.t_with_gender(key, gender);
793        self.replace_placeholders(&template, args)
794    }
795
796    // Private utility methods
797    fn get_text_value(&self, key: &str) -> Option<String> {
798        self.file_traductions
799            .get(key)
800            .and_then(|v| if let SectionValue::Text(s) = v { Some(s.clone()) } else { None })
801            .or_else(|| {
802                self.fallback_traduction
803                    .get(key)
804                    .and_then(|v| (
805                        if let SectionValue::Text(s) = v {
806                            Some(s.clone())
807                        } else {
808                            None
809                        }
810                    ))
811            })
812    }
813
814    fn get_nested_value(&self, key: &str, nested_key: &str) -> Option<String> {
815        self.file_traductions
816            .get(key)
817            .and_then(|v| (
818                if let SectionValue::Map(m) = v {
819                    m.get(nested_key).cloned()
820                } else {
821                    None
822                }
823            ))
824            .or_else(|| {
825                self.fallback_traduction
826                    .get(key)
827                    .and_then(|v| (
828                        if let SectionValue::Map(m) = v {
829                            m.get(nested_key).cloned()
830                        } else {
831                            None
832                        }
833                    ))
834            })
835    }
836
837    fn replace_placeholders(&self, template: &str, args: &[&dyn ToString]) -> String {
838        let parts: Vec<&str> = ARG_RE.split(template).collect();
839        let mut result = String::new();
840
841        for (i, part) in parts.iter().enumerate() {
842            result.push_str(part);
843            if i < args.len() {
844                result.push_str(&args[i].to_string());
845            }
846        }
847
848        result
849    }
850}
851
852// ---------- Utils ----------
853
854/// Checks if a locale string exists as an international standard.
855/// 
856/// Uses the built-in LOCALES list to validate locale codes against
857/// international standards (ISO 639-1, ISO 3166-1, etc.).
858fn locale_exists_as_international_standard(locale: &str) -> bool {
859    LOCALES.binary_search(&locale).is_ok()
860}