Skip to main content

tsz_cli/
locale.rs

1//! Internationalization (i18n) support for diagnostic messages.
2//!
3//! This module provides locale-specific diagnostic messages matching TypeScript's
4//! localization. The translation files are copied from TypeScript's npm package.
5//!
6//! # Supported Locales
7//!
8//! - `cs` - Czech
9//! - `de` - German
10//! - `es` - Spanish
11//! - `fr` - French
12//! - `it` - Italian
13//! - `ja` - Japanese
14//! - `ko` - Korean
15//! - `pl` - Polish
16//! - `pt-br` - Portuguese (Brazil)
17//! - `ru` - Russian
18//! - `tr` - Turkish
19//! - `zh-cn` - Chinese (Simplified)
20//! - `zh-tw` - Chinese (Traditional)
21//!
22//! # Usage
23//!
24//! ```ignore
25//! use tsz::cli::locale::LocaleMessages;
26//!
27//! let locale = LocaleMessages::load("ja").unwrap_or_default();
28//! let message = locale.get_message(2304, "Cannot find name '{0}'.");
29//! ```
30
31use rustc_hash::FxHashMap;
32use std::sync::OnceLock;
33
34/// Global locale state for the current process.
35static LOCALE: OnceLock<LocaleMessages> = OnceLock::new();
36
37/// Container for locale-specific diagnostic messages.
38#[derive(Debug, Default)]
39pub struct LocaleMessages {
40    /// Map from diagnostic code to translated message template.
41    messages: FxHashMap<u32, String>,
42    /// The locale identifier (e.g., "ja", "de").
43    locale_id: String,
44}
45
46impl LocaleMessages {
47    /// Load a locale from the embedded locale files.
48    ///
49    /// Returns `None` if the locale is not supported or fails to parse.
50    pub fn load(locale_id: &str) -> Option<Self> {
51        let normalized = normalize_locale(locale_id)?;
52        let json_content = get_locale_content(normalized)?;
53        let messages = parse_locale_json(json_content)?;
54
55        Some(Self {
56            messages,
57            locale_id: normalized.to_string(),
58        })
59    }
60
61    /// Get the translated message for a diagnostic code.
62    ///
63    /// Returns the translated message if available, otherwise returns the fallback.
64    pub fn get_message<'a>(&self, code: u32, fallback: &'a str) -> &'a str {
65        // Note: We return the fallback because the translated message has a different
66        // lifetime. In practice, callers should use `get_message_owned` for translations.
67        if self.messages.contains_key(&code) {
68            // Translation exists but we return fallback due to lifetime constraints
69            // The caller should use get_message_owned for actual translation
70            fallback
71        } else {
72            fallback
73        }
74    }
75
76    /// Get the translated message for a diagnostic code, returning an owned String.
77    ///
78    /// Returns the translated message if available, otherwise returns the fallback.
79    pub fn get_message_owned(&self, code: u32, fallback: &str) -> String {
80        self.messages
81            .get(&code)
82            .cloned()
83            .unwrap_or_else(|| fallback.to_string())
84    }
85
86    /// Check if this locale has a translation for the given code.
87    pub fn has_translation(&self, code: u32) -> bool {
88        self.messages.contains_key(&code)
89    }
90
91    /// Get the locale identifier.
92    pub fn locale_id(&self) -> &str {
93        &self.locale_id
94    }
95
96    /// Returns true if this is the default (English) locale.
97    pub const fn is_default(&self) -> bool {
98        self.locale_id.is_empty()
99    }
100}
101
102/// Initialize the global locale. Should be called once at startup.
103pub fn init_locale(locale_id: Option<&str>) {
104    let locale = locale_id.and_then(LocaleMessages::load).unwrap_or_default();
105    let _ = LOCALE.set(locale);
106}
107
108/// Get the current global locale.
109pub fn get_locale() -> &'static LocaleMessages {
110    LOCALE.get_or_init(LocaleMessages::default)
111}
112
113/// Get a translated message using the global locale.
114///
115/// This function attempts to extract parameters from the fallback message
116/// and substitute them into the translated template.
117pub fn translate(code: u32, fallback: &str) -> String {
118    let locale = get_locale();
119
120    // If no translation available or default locale, return fallback
121    if locale.is_default() || !locale.has_translation(code) {
122        return fallback.to_string();
123    }
124
125    let template = locale.get_message_owned(code, fallback);
126
127    // If template doesn't have placeholders, return it directly
128    if !template.contains("{0}") {
129        return template;
130    }
131
132    // Extract parameters from the fallback message and substitute into template
133    substitute_params_from_english(code, &template, fallback)
134}
135
136/// Extract parameters from an English formatted message and substitute into a translated template.
137///
138/// This works by matching common patterns in TypeScript diagnostic messages.
139/// For example, for TS2322 "Type 'X' is not assignable to type 'Y'":
140/// - English formatted: "Type 'string' is not assignable to type 'number'."
141/// - Template: "型 '{0}' を型 '{1}' に割り当てることはできません。"
142/// - Result: "型 'string' を型 'number' に割り当てることはできません。"
143fn substitute_params_from_english(_code: u32, template: &str, formatted_english: &str) -> String {
144    // Extract quoted strings from the formatted English message
145    // TypeScript typically uses single quotes around parameter values
146    let params = extract_quoted_strings(formatted_english);
147
148    // Substitute parameters into the template
149    let mut result = template.to_string();
150    for (i, param) in params.iter().enumerate() {
151        let placeholder = format!("{{{i}}}");
152        result = result.replace(&placeholder, param);
153    }
154
155    result
156}
157
158/// Extract single-quoted strings from a message.
159///
160/// Returns the strings in order of appearance, without the surrounding quotes.
161fn extract_quoted_strings(message: &str) -> Vec<&str> {
162    let mut params = Vec::new();
163    let mut chars = message.char_indices().peekable();
164
165    while let Some((idx, ch)) = chars.next() {
166        if ch == '\'' {
167            // Find the closing quote
168            let content_start = idx + 1;
169
170            while let Some((pos, c)) = chars.next() {
171                if c == '\'' {
172                    // Check for escaped quote ('')
173                    if let Some((_, next)) = chars.peek()
174                        && *next == '\''
175                    {
176                        // Skip the escaped quote
177                        chars.next();
178                        continue;
179                    }
180                    // Found closing quote
181                    if content_start < pos {
182                        params.push(&message[content_start..pos]);
183                    }
184                    break;
185                }
186            }
187        }
188    }
189
190    params
191}
192
193/// Normalize a locale identifier to our supported format.
194fn normalize_locale(locale: &str) -> Option<&'static str> {
195    let lower = locale.to_lowercase();
196    match lower.as_str() {
197        "cs" | "cs-cz" | "czech" => Some("cs"),
198        "de" | "de-de" | "de-at" | "de-ch" | "german" => Some("de"),
199        "es" | "es-es" | "es-mx" | "spanish" => Some("es"),
200        "fr" | "fr-fr" | "fr-ca" | "french" => Some("fr"),
201        "it" | "it-it" | "italian" => Some("it"),
202        "ja" | "ja-jp" | "japanese" => Some("ja"),
203        "ko" | "ko-kr" | "korean" => Some("ko"),
204        "pl" | "pl-pl" | "polish" => Some("pl"),
205        "pt-br" | "pt" | "portuguese" => Some("pt-br"),
206        "ru" | "ru-ru" | "russian" => Some("ru"),
207        "tr" | "tr-tr" | "turkish" => Some("tr"),
208        "zh-cn" | "zh-hans" | "zh" | "chinese" => Some("zh-cn"),
209        "zh-tw" | "zh-hant" => Some("zh-tw"),
210        _ => None,
211    }
212}
213
214/// Get the embedded locale content.
215fn get_locale_content(locale: &str) -> Option<&'static str> {
216    match locale {
217        "cs" => Some(include_str!("locales/cs.json")),
218        "de" => Some(include_str!("locales/de.json")),
219        "es" => Some(include_str!("locales/es.json")),
220        "fr" => Some(include_str!("locales/fr.json")),
221        "it" => Some(include_str!("locales/it.json")),
222        "ja" => Some(include_str!("locales/ja.json")),
223        "ko" => Some(include_str!("locales/ko.json")),
224        "pl" => Some(include_str!("locales/pl.json")),
225        "pt-br" => Some(include_str!("locales/pt-br.json")),
226        "ru" => Some(include_str!("locales/ru.json")),
227        "tr" => Some(include_str!("locales/tr.json")),
228        "zh-cn" => Some(include_str!("locales/zh-cn.json")),
229        "zh-tw" => Some(include_str!("locales/zh-tw.json")),
230        _ => None,
231    }
232}
233
234/// Parse a TypeScript locale JSON file into a code -> message map.
235///
236/// TypeScript's locale files have keys like:
237/// - `Cannot_find_name_0_2304` -> code 2304
238/// - `Type_0_is_not_assignable_to_type_1_2322` -> code 2322
239///
240/// The code is always the last number after the final underscore.
241fn parse_locale_json(json: &str) -> Option<FxHashMap<u32, String>> {
242    let parsed: serde_json::Value = serde_json::from_str(json).ok()?;
243    let obj = parsed.as_object()?;
244
245    let mut messages = FxHashMap::default();
246    for (key, value) in obj {
247        if let Some(code) = extract_code_from_key(key)
248            && let Some(msg) = value.as_str()
249        {
250            messages.insert(code, msg.to_string());
251        }
252    }
253
254    Some(messages)
255}
256
257/// Extract the diagnostic code from a locale key.
258///
259/// TypeScript keys look like: `Cannot_find_name_0_2304`
260/// The code is the final number segment after the last underscore.
261fn extract_code_from_key(key: &str) -> Option<u32> {
262    // Find the last underscore and parse what follows as a number
263    let last_underscore = key.rfind('_')?;
264    let code_str = &key[last_underscore + 1..];
265    code_str.parse().ok()
266}
267
268/// Get a list of all supported locale identifiers.
269pub const fn supported_locales() -> &'static [&'static str] {
270    &[
271        "cs", "de", "es", "fr", "it", "ja", "ko", "pl", "pt-br", "ru", "tr", "zh-cn", "zh-tw",
272    ]
273}
274
275#[cfg(test)]
276#[path = "locale_tests.rs"]
277mod tests;