Skip to main content

trait_kit/i18n/
mod.rs

1// Copyright (c) 2026 Kirky.X
2// SPDX-License-Identifier: MIT
3//! 国际化(i18n)支持 — Fluent FTL 消息翻译 + ICU4X 本地化格式化。
4//!
5//! 提供两大能力:
6//!
7//! 1. **消息翻译**([`I18nManager`] + [`tr`]):基于 Fluent FTL 消息文件,
8//!    支持中英文切换,系统语言环境自动检测。
9//! 2. **本地化格式化**([`I18nFormatter`]):ICU4X 驱动的数字/日期/复数/排序格式化。
10//!
11//! # 启动初始化
12//!
13//! 在应用启动时调用 [`I18nManager::init`] 自动检测系统语言环境:
14//!
15//! ```rust
16//! use trait_kit::i18n::I18nManager;
17//!
18//! let mgr = I18nManager::init();
19//! ```
20//!
21//! 或使用指定 locale:
22//!
23//! ```rust
24//! use trait_kit::i18n::I18nManager;
25//!
26//! let mgr = I18nManager::init_with_locale("zh-CN").expect("zh-CN locale");
27//! ```
28
29#[cfg(feature = "i18n")]
30mod i18n_impl;
31mod messages;
32
33use std::collections::HashMap;
34use std::fmt;
35use std::sync::OnceLock;
36
37#[cfg(feature = "i18n")]
38use icu::collator::CollatorBorrowed;
39#[cfg(feature = "i18n")]
40use icu::decimal::DecimalFormatter;
41#[cfg(feature = "i18n")]
42use icu::locale::Locale;
43#[cfg(feature = "i18n")]
44use icu::plurals::PluralRules;
45
46// ─── I18nError ──────────────────────────────────────────────────────────────
47
48/// 国际化操作返回的错误类型。
49#[derive(Debug, Clone)]
50pub enum I18nError {
51    /// BCP-47 locale 字符串解析失败。
52    InvalidLocale {
53        /// 原始输入。
54        input: String,
55        /// 失败原因。
56        reason: String,
57    },
58    /// 数值无法格式化(如 NaN、Infinity 或解析失败)。
59    InvalidNumber {
60        /// 原始输入。
61        input: String,
62        /// 失败原因。
63        reason: String,
64    },
65    /// 日期分量越界或无效。
66    DateError(String),
67    /// ICU4X 数据或格式化失败。
68    FormatError(String),
69}
70
71impl fmt::Display for I18nError {
72    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
73        // tr() 通过 OnceLock 惰性初始化,build() 不调用 Display,
74        // 因此不存在递归风险。
75        match self {
76            Self::InvalidLocale { input, reason } => {
77                write!(
78                    f,
79                    "{}",
80                    tr(
81                        "i18n-error-invalid-locale",
82                        &[("input", input), ("reason", reason)]
83                    ),
84                )
85            }
86            Self::InvalidNumber { input, reason } => {
87                write!(
88                    f,
89                    "{}",
90                    tr(
91                        "i18n-error-invalid-number",
92                        &[("input", input), ("reason", reason)]
93                    ),
94                )
95            }
96            Self::DateError(detail) => {
97                write!(f, "{}", tr("i18n-error-date", &[("detail", detail)]))
98            }
99            Self::FormatError(detail) => {
100                write!(f, "{}", tr("i18n-error-format", &[("detail", detail)]))
101            }
102        }
103    }
104}
105
106impl std::error::Error for I18nError {}
107
108// ─── I18nFormatter(ICU4X 格式化) ──────────────────────────────────────────
109
110/// 基于 ICU4X 编译数据的 locale 感知格式化器。
111///
112/// 通过 BCP-47 locale 标签(如 `"en-US"`、`"zh-CN"`)构造。
113/// 所有格式化器在构造时 eagerly 创建,后续格式化调用低分配。
114#[cfg(feature = "i18n")]
115#[derive(Debug)]
116pub struct I18nFormatter {
117    /// 已解析的 locale。
118    pub(crate) locale: Locale,
119    /// 小数(数字)格式化器。
120    pub(crate) decimal_formatter: DecimalFormatter,
121    /// 该 locale 的复数规则。
122    pub(crate) plural_rules: PluralRules,
123    /// 字符串排序比较器。
124    pub(crate) collator: CollatorBorrowed<'static>,
125}
126
127// ─── MessageCatalog(轻量级 FTL 消息翻译) ──────────────────────────────────
128
129/// 轻量级 FTL 消息目录。
130///
131/// 解析 Fluent FTL 格式的 `key = value` 消息,支持 `{ $var }` 变量替换。
132/// 无需 `fluent-bundle` 运行时,避免自引用结构问题。
133#[derive(Debug)]
134struct MessageCatalog {
135    messages: HashMap<String, String>,
136}
137
138impl MessageCatalog {
139    /// 从 FTL 格式字符串解析消息目录。
140    ///
141    /// 支持的格式:
142    /// - `# 注释行`(忽略)
143    /// - 空行(忽略)
144    /// - `message-id = 消息文本`(解析为 key-value)
145    fn parse(ftl: &str) -> Self {
146        let mut messages = HashMap::new();
147        for line in ftl.lines() {
148            let line = line.trim();
149            if line.is_empty() || line.starts_with('#') {
150                continue;
151            }
152            if let Some((key, value)) = line.split_once('=') {
153                messages.insert(key.trim().to_string(), value.trim().to_string());
154            }
155        }
156        Self { messages }
157    }
158
159    /// 翻译消息 key,带参数替换。
160    ///
161    /// `{ $var }` 占位符被替换为 `args` 中对应的值。
162    /// 如果 key 不存在,返回 key 本身作为 fallback。
163    fn translate(&self, message_id: &str, args: &[(&str, &str)]) -> String {
164        let Some(template) = self.messages.get(message_id) else {
165            return message_id.to_string();
166        };
167        let mut result = template.clone();
168        for &(key, value) in args {
169            // 替换 { $key } 及其变体(允许不同空格)
170            let pattern = format!("{{ ${key} }}");
171            result = result.replace(&pattern, value);
172        }
173        result
174    }
175}
176
177// ─── I18nManager(全局状态 + 消息翻译) ─────────────────────────────────────
178
179/// 全局 [`I18nManager`] 实例。
180static GLOBAL_I18N: OnceLock<I18nManager> = OnceLock::new();
181
182/// Fluent 消息翻译管理器。
183///
184/// 持有 FTL 消息目录和当前 locale 信息。
185/// 通过 [`I18nManager::init`] 自动检测系统语言环境并初始化。
186#[derive(Debug)]
187pub struct I18nManager {
188    catalog: MessageCatalog,
189    locale_tag: String,
190}
191
192impl I18nManager {
193    /// 检测系统语言环境并初始化全局管理器。
194    ///
195    /// 首次调用时根据系统 locale 加载对应的 FTL 消息文件。
196    /// 后续调用直接返回已初始化的实例。
197    pub fn init() -> &'static Self {
198        GLOBAL_I18N.get_or_init(|| {
199            #[cfg(feature = "i18n")]
200            let locale_str = detect_system_locale();
201            #[cfg(not(feature = "i18n"))]
202            let locale_str = String::from("en-US");
203            Self::build(&locale_str)
204        })
205    }
206
207    /// 使用指定 BCP-47 locale 标签初始化全局管理器。
208    ///
209    /// # Errors
210    ///
211    /// 返回 [`I18nError::InvalidLocale`] 如果全局管理器已初始化。
212    ///
213    /// # Panics
214    ///
215    /// 不会 panic。`OnceLock::set` 失败后通过 `unwrap` 获取的是已设置的值,保证安全。
216    pub fn init_with_locale(locale: &str) -> Result<&'static Self, I18nError> {
217        let manager = Self::build(locale);
218        GLOBAL_I18N
219            .set(manager)
220            .map_err(|_| I18nError::InvalidLocale {
221                input: locale.to_string(),
222                reason: "global I18nManager already initialized".into(),
223            })?;
224        Ok(GLOBAL_I18N.get().unwrap())
225    }
226
227    /// 获取全局 [`I18nManager`] 实例。
228    ///
229    /// 如果 [`init`](Self::init) 或 [`init_with_locale`](Self::init_with_locale)
230    /// 尚未调用,返回 `None`。
231    #[must_use]
232    pub fn global() -> Option<&'static I18nManager> {
233        GLOBAL_I18N.get()
234    }
235
236    /// 翻译消息 key,带参数替换。
237    ///
238    /// 如果消息 key 不存在,返回 key 本身作为 fallback。
239    #[must_use]
240    pub fn translate(&self, message_id: &str, args: &[(&str, &str)]) -> String {
241        self.catalog.translate(message_id, args)
242    }
243
244    /// 当前 locale 的 BCP-47 标签。
245    #[must_use]
246    pub fn locale_tag(&self) -> &str {
247        &self.locale_tag
248    }
249
250    /// 内部构造:根据 locale 选择 FTL 内容并解析。
251    fn build(locale: &str) -> Self {
252        let ftl_content = if locale.to_lowercase().starts_with("zh") {
253            messages::ZH_FTL
254        } else {
255            messages::EN_FTL
256        };
257        Self {
258            catalog: MessageCatalog::parse(ftl_content),
259            locale_tag: locale.to_string(),
260        }
261    }
262}
263
264/// 便捷函数:翻译消息 key。
265///
266/// 如果全局 [`I18nManager`] 未初始化,自动调用 [`I18nManager::init`]。
267/// 如果消息 key 不存在,返回 key 本身作为 fallback。
268///
269/// # 示例
270///
271/// ```rust
272/// use trait_kit::i18n::tr;
273///
274/// let msg = tr("trait-kit-error-already-registered", &[("module", "my-module")]);
275/// ```
276#[must_use]
277pub fn tr(message_id: &str, args: &[(&str, &str)]) -> String {
278    let mgr = I18nManager::init();
279    mgr.translate(message_id, args)
280}
281
282/// 检测系统语言环境,返回 BCP-47 标签。
283///
284/// 如果检测失败或返回空值,回退到 `"en-US"`。
285#[cfg(feature = "i18n")]
286fn detect_system_locale() -> String {
287    sys_locale::get_locale().unwrap_or_else(|| "en-US".to_string())
288}
289
290// ─── Tests ──────────────────────────────────────────────────────────────────
291
292#[cfg(test)]
293mod tests {
294    use super::*;
295    use std::cmp::Ordering;
296
297    #[cfg(feature = "i18n")]
298    use icu::plurals::PluralCategory;
299
300    // ─── MessageCatalog 测试 ────────────────────────────────────────────────
301
302    #[test]
303    fn catalog_parse_simple_ftl() {
304        let catalog = MessageCatalog::parse("hello = Hello, world!\nbye = Goodbye!");
305        assert_eq!(catalog.translate("hello", &[]), "Hello, world!");
306        assert_eq!(catalog.translate("bye", &[]), "Goodbye!");
307    }
308
309    #[test]
310    fn catalog_parse_skips_comments_and_blanks() {
311        let ftl = "# comment\n\nkey = value\n# another comment\n";
312        let catalog = MessageCatalog::parse(ftl);
313        assert_eq!(catalog.translate("key", &[]), "value");
314    }
315
316    #[test]
317    fn catalog_translate_with_variables() {
318        let catalog = MessageCatalog::parse("greet = Hello, { $name }!");
319        let result = catalog.translate("greet", &[("name", "World")]);
320        assert_eq!(result, "Hello, World!");
321    }
322
323    #[test]
324    fn catalog_translate_unknown_key_returns_key() {
325        let catalog = MessageCatalog::parse("key = value");
326        assert_eq!(catalog.translate("unknown", &[]), "unknown");
327    }
328
329    // ─── I18nManager 测试 ───────────────────────────────────────────────────
330
331    #[test]
332    fn manager_init_returns_valid_instance() {
333        let mgr = I18nManager::init();
334        assert!(
335            !mgr.locale_tag().is_empty(),
336            "locale tag should be non-empty"
337        );
338    }
339
340    #[test]
341    fn manager_translate_message() {
342        let mgr = I18nManager::init();
343        let msg = mgr.translate(
344            "trait-kit-error-already-registered",
345            &[("module", "test-mod")],
346        );
347        assert!(
348            msg.contains("test-mod"),
349            "translated message should contain module name: got '{msg}'"
350        );
351    }
352
353    #[test]
354    fn manager_translate_unknown_key_returns_key() {
355        let mgr = I18nManager::init();
356        let msg = mgr.translate("nonexistent-key", &[]);
357        assert_eq!(msg, "nonexistent-key");
358    }
359
360    #[test]
361    fn tr_convenience_function_works() {
362        let msg = tr("trait-kit-error-missing-capability", &[("key", "my-cap")]);
363        assert!(
364            msg.contains("my-cap"),
365            "tr() output should contain key: got '{msg}'"
366        );
367    }
368
369    // ─── I18nFormatter 测试 ─────────────────────────────────────────────────
370
371    #[cfg(feature = "i18n")]
372    #[test]
373    fn test_locale_parsing_en() {
374        let fmt = I18nFormatter::new("en-US");
375        assert!(fmt.is_ok(), "en-US should parse successfully");
376        let fmt = fmt.unwrap();
377        assert_eq!(fmt.locale.to_string(), "en-US");
378    }
379
380    #[cfg(feature = "i18n")]
381    #[test]
382    fn test_locale_parsing_zh() {
383        let fmt = I18nFormatter::new("zh-CN");
384        assert!(fmt.is_ok(), "zh-CN should parse successfully");
385        let fmt = fmt.unwrap();
386        assert_eq!(fmt.locale.to_string(), "zh-CN");
387    }
388
389    #[cfg(feature = "i18n")]
390    #[test]
391    fn test_invalid_locale() {
392        let result = I18nFormatter::new("not-a-valid-locale!!!");
393        assert!(result.is_err(), "invalid locale should return error");
394        match result.err().unwrap() {
395            I18nError::InvalidLocale { input, .. } => assert_eq!(input, "not-a-valid-locale!!!"),
396            other => panic!("expected InvalidLocale, got {other:?}"),
397        }
398    }
399
400    #[cfg(feature = "i18n")]
401    #[test]
402    fn test_format_number_en() {
403        let fmt = I18nFormatter::new("en-US").expect("en-US locale");
404        let result = fmt.format_number(1_234_567.89_f64).expect("format number");
405        assert!(
406            result.contains(','),
407            "en-US number should contain thousands separator: got '{result}'"
408        );
409        assert!(
410            result.contains('.'),
411            "en-US number should contain decimal point: got '{result}'"
412        );
413    }
414
415    #[cfg(feature = "i18n")]
416    #[test]
417    fn test_format_number_zh() {
418        let fmt = I18nFormatter::new("zh-CN").expect("zh-CN locale");
419        let result = fmt.format_number(1_234_567.89_f64).expect("format number");
420        assert!(
421            !result.is_empty(),
422            "zh-CN number should be non-empty: got '{result}'"
423        );
424    }
425
426    #[cfg(feature = "i18n")]
427    #[test]
428    fn test_format_number_not_finite() {
429        let fmt = I18nFormatter::new("en-US").expect("en-US locale");
430        assert!(fmt.format_number(f64::NAN).is_err());
431        assert!(fmt.format_number(f64::INFINITY).is_err());
432    }
433
434    #[cfg(feature = "i18n")]
435    #[test]
436    fn test_plural_rules_en() {
437        let fmt = I18nFormatter::new("en").expect("en locale");
438        assert_eq!(
439            fmt.plural_category(1).expect("plural 1"),
440            PluralCategory::One,
441            "en: count=1 should be One"
442        );
443        assert_eq!(
444            fmt.plural_category(2).expect("plural 2"),
445            PluralCategory::Other,
446            "en: count=2 should be Other"
447        );
448        assert_eq!(
449            fmt.plural_category(0).expect("plural 0"),
450            PluralCategory::Other,
451            "en: count=0 should be Other"
452        );
453    }
454
455    #[cfg(feature = "i18n")]
456    #[test]
457    fn test_collator_basic() {
458        let fmt = I18nFormatter::new("en").expect("en locale");
459        assert_eq!(
460            fmt.compare("apple", "banana").expect("compare"),
461            Ordering::Less,
462            "apple < banana"
463        );
464        assert_eq!(
465            fmt.compare("banana", "apple").expect("compare"),
466            Ordering::Greater,
467            "banana > apple"
468        );
469        assert_eq!(
470            fmt.compare("apple", "apple").expect("compare"),
471            Ordering::Equal,
472            "apple == apple"
473        );
474    }
475
476    #[cfg(feature = "i18n")]
477    #[test]
478    fn test_format_date_en() {
479        let fmt = I18nFormatter::new("en-US").expect("en-US locale");
480        let result = fmt.format_date(2026, 7, 11).expect("format date");
481        assert!(
482            result.contains("2026"),
483            "date should contain year: got '{result}'"
484        );
485        assert!(
486            !result.is_empty(),
487            "date should be non-empty: got '{result}'"
488        );
489    }
490
491    #[cfg(feature = "i18n")]
492    #[test]
493    fn test_format_date_invalid_month() {
494        let fmt = I18nFormatter::new("en-US").expect("en-US locale");
495        let result = fmt.format_date(2026, 13, 1);
496        assert!(result.is_err(), "month 13 should be invalid");
497        assert!(matches!(result.unwrap_err(), I18nError::DateError(_)));
498    }
499
500    #[cfg(feature = "i18n")]
501    #[test]
502    fn test_format_date_invalid_day() {
503        let fmt = I18nFormatter::new("en-US").expect("en-US locale");
504        let result = fmt.format_date(2026, 2, 30);
505        assert!(result.is_err(), "Feb 30 should be invalid");
506        assert!(matches!(result.unwrap_err(), I18nError::DateError(_)));
507    }
508
509    #[cfg(feature = "i18n")]
510    #[test]
511    fn test_format_number_integer() {
512        let fmt = I18nFormatter::new("en-US").expect("en-US locale");
513        let result = fmt.format_number(42.0).expect("format integer-like float");
514        assert!(
515            result.contains('4'),
516            "should contain digit 4: got '{result}'"
517        );
518    }
519
520    #[cfg(feature = "i18n")]
521    #[test]
522    fn test_plural_category_zero() {
523        let fmt = I18nFormatter::new("zh-CN").expect("zh-CN locale");
524        let cat = fmt.plural_category(0).expect("plural 0");
525        assert_eq!(
526            cat,
527            PluralCategory::Other,
528            "Chinese uses Other for all counts"
529        );
530    }
531
532    #[cfg(feature = "i18n")]
533    #[test]
534    fn test_compare_equal_strings() {
535        let fmt = I18nFormatter::new("de-DE").expect("de-DE locale");
536        let result = fmt.compare("abc", "abc").expect("compare");
537        assert_eq!(result, Ordering::Equal);
538    }
539
540    // ─── I18nError Display 测试 ─────────────────────────────────────────────
541
542    #[test]
543    fn error_display_invalid_locale() {
544        let err = I18nError::InvalidLocale {
545            input: "bad".into(),
546            reason: "parse failed".into(),
547        };
548        let msg = err.to_string();
549        assert!(
550            msg.contains("bad"),
551            "error display should contain input: got '{msg}'"
552        );
553    }
554
555    #[test]
556    fn error_display_date_error() {
557        let err = I18nError::DateError("month out of range".into());
558        let msg = err.to_string();
559        assert!(
560            msg.contains("month out of range"),
561            "error display should contain detail: got '{msg}'"
562        );
563    }
564
565    #[test]
566    fn error_display_invalid_number() {
567        let err = I18nError::InvalidNumber {
568            input: "NaN".into(),
569            reason: "not finite".into(),
570        };
571        let msg = err.to_string();
572        assert!(msg.contains("NaN"), "should contain input: got '{msg}'");
573    }
574
575    #[test]
576    fn error_display_format_error() {
577        let err = I18nError::FormatError("formatting failed".into());
578        let msg = err.to_string();
579        assert!(msg.contains("formatting failed"), "got '{msg}'");
580    }
581
582    #[test]
583    fn i18n_manager_init_with_locale() {
584        // init_with_locale uses a separate OnceLock from the convenience constructor.
585        // This may fail if already initialized in another test, which is fine.
586        let _ = I18nManager::init_with_locale("en-US");
587    }
588
589    #[test]
590    fn i18n_manager_global_returns_some_after_init() {
591        let _ = I18nManager::init_with_locale("en-US");
592        assert!(I18nManager::global().is_some());
593    }
594
595    #[test]
596    fn i18n_manager_translate_and_locale_tag() {
597        let manager = I18nManager::build("en-US");
598        let tag = manager.locale_tag();
599        assert_eq!(tag, "en-US");
600        let msg = manager.translate("nonexistent-key", &[]);
601        assert_eq!(msg, "nonexistent-key");
602    }
603
604    #[test]
605    fn i18n_manager_build_zh_cn() {
606        let manager = I18nManager::build("zh-CN");
607        assert_eq!(manager.locale_tag(), "zh-CN");
608    }
609}