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}