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