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}