1use std::collections::HashMap;
10use std::sync::atomic::{AtomicU8, Ordering};
11
12use once_cell::sync::Lazy;
13
14mod catalog;
15
16#[derive(Debug, Clone, Copy, PartialEq, Eq)]
18pub enum Lang {
19 En,
20 Zh,
21}
22
23impl Lang {
24 pub fn code(self) -> &'static str {
26 match self {
27 Lang::En => "en",
28 Lang::Zh => "zh",
29 }
30 }
31
32 pub fn parse(s: &str) -> Option<Lang> {
35 let s = s.trim().to_ascii_lowercase();
36 if s.is_empty() {
37 return None;
38 }
39 if s == "中文" {
40 return Some(Lang::Zh);
41 }
42 let head = s
43 .split(['.', '_', '-', '@', ' '])
44 .next()
45 .unwrap_or(s.as_str());
46 match head {
47 "en" | "english" | "c" | "posix" => Some(Lang::En),
48 "zh" | "chinese" | "cn" => Some(Lang::Zh),
49 _ => None,
50 }
51 }
52
53 fn from_locale(s: &str) -> Option<Lang> {
55 Lang::parse(s)
56 }
57}
58
59static ACTIVE: AtomicU8 = AtomicU8::new(0);
61
62fn lang_from_u8(v: u8) -> Lang {
63 match v {
64 1 => Lang::Zh,
65 _ => Lang::En,
66 }
67}
68
69pub fn set_lang(lang: Lang) {
71 let v = match lang {
72 Lang::En => 0,
73 Lang::Zh => 1,
74 };
75 ACTIVE.store(v, Ordering::Relaxed);
76}
77
78pub fn current() -> Lang {
80 lang_from_u8(ACTIVE.load(Ordering::Relaxed))
81}
82
83pub fn detect(explicit: Option<&str>, getenv: impl Fn(&str) -> Option<String>) -> Lang {
88 if let Some(sel) = explicit {
89 if let Some(l) = Lang::parse(sel) {
90 return l;
91 }
92 }
93 if let Some(v) = getenv("OSDK_LANG") {
94 if let Some(l) = Lang::parse(&v) {
95 return l;
96 }
97 }
98 for key in ["LC_ALL", "LC_MESSAGES", "LANG"] {
99 if let Some(v) = getenv(key) {
100 if let Some(l) = Lang::from_locale(&v) {
101 return l;
102 }
103 }
104 }
105 Lang::En
106}
107
108type Row = (&'static str, &'static str);
110static CATALOG: Lazy<HashMap<&'static str, Row>> = Lazy::new(catalog::build);
111
112pub fn tr(key: &str) -> String {
115 trl(current(), key)
116}
117
118pub fn trl(lang: Lang, key: &str) -> String {
120 match CATALOG.get(key) {
121 Some((en, zh)) => match lang {
122 Lang::En => en.to_string(),
123 Lang::Zh => {
124 if zh.is_empty() {
125 en.to_string()
126 } else {
127 zh.to_string()
128 }
129 }
130 },
131 None => key.to_string(),
132 }
133}
134
135pub fn trf(key: &str, args: &[(&str, &str)]) -> String {
137 interpolate(&tr(key), args)
138}
139
140pub fn interpolate(template: &str, args: &[(&str, &str)]) -> String {
142 let mut out = template.to_string();
143 for (k, v) in args {
144 out = out.replace(&format!("{{{k}}}"), v);
145 }
146 out
147}
148
149#[macro_export]
152macro_rules! t {
153 ($key:expr) => {
154 $crate::i18n::tr($key)
155 };
156 ($key:expr, $($name:ident = $val:expr),+ $(,)?) => {{
157 let args: &[(&str, &str)] = &[$((stringify!($name), &format!("{}", $val))),+];
158 $crate::i18n::trf($key, args)
159 }};
160}
161
162#[cfg(test)]
163mod tests {
164 use super::*;
165
166 #[test]
167 fn parse_langs() {
168 assert_eq!(Lang::parse("zh_CN.UTF-8"), Some(Lang::Zh));
169 assert_eq!(Lang::parse("en_US.UTF-8"), Some(Lang::En));
170 assert_eq!(Lang::parse("zh-Hans"), Some(Lang::Zh));
171 assert_eq!(Lang::parse("中文"), Some(Lang::Zh));
172 assert_eq!(Lang::parse("C"), Some(Lang::En));
173 assert_eq!(Lang::parse("fr"), None);
174 }
175
176 #[test]
177 fn detect_precedence() {
178 let l = detect(Some("zh"), |k| {
180 if k == "LANG" {
181 Some("en_US.UTF-8".into())
182 } else {
183 None
184 }
185 });
186 assert_eq!(l, Lang::Zh);
187 let l = detect(None, |k| match k {
189 "OSDK_LANG" => Some("en".into()),
190 "LANG" => Some("zh_CN.UTF-8".into()),
191 _ => None,
192 });
193 assert_eq!(l, Lang::En);
194 let l = detect(None, |k| {
196 if k == "LANG" {
197 Some("zh_CN.UTF-8".into())
198 } else {
199 None
200 }
201 });
202 assert_eq!(l, Lang::Zh);
203 assert_eq!(detect(None, |_| None), Lang::En);
205 }
206
207 #[test]
208 fn interpolation() {
209 assert_eq!(
210 interpolate("hello {name}!", &[("name", "world")]),
211 "hello world!"
212 );
213 }
214
215 #[test]
216 fn tr_falls_back_to_key() {
217 assert_eq!(
218 trl(Lang::En, "definitely.missing.key"),
219 "definitely.missing.key"
220 );
221 }
222
223 #[test]
224 fn log_keys_localized_both_langs() {
225 for key in [
228 "log.checksum_verified",
229 "log.download_failover",
230 "log.stale_python_cache",
231 ] {
232 let en = trl(Lang::En, key);
233 let zh = trl(Lang::Zh, key);
234 assert_ne!(en, key, "missing en for {key}");
235 assert_ne!(zh, key, "missing zh for {key}");
236 assert_ne!(en, zh, "zh not translated for {key}");
237 }
238 let msg = trl(Lang::Zh, "log.download_failover");
240 assert!(msg.contains("{err}"));
241 assert_eq!(
242 interpolate(&msg, &[("err", "boom")]),
243 msg.replace("{err}", "boom")
244 );
245 }
246
247 #[test]
248 fn dynamic_npm_keys_are_bilingual_with_matching_placeholders() {
249 for key in [
250 "label.npm_lock_graph",
251 "err.npm_managed_node_dependency_required",
252 "err.npm_package_backend_invalid",
253 "err.shim_generation_conflict",
254 "err.shim_managed_node_required",
255 "err.shim_dynamic_route_conflict",
256 "err.npm_allow_builds_invalid",
257 "err.npm_lock_graph_option_missing",
258 "err.npm_lock_graph_identity_mismatch",
259 "err.npm_lock_graph_format_unsupported",
260 "err.npm_lock_graph_digest_invalid",
261 "err.npm_lock_graph_tool_mismatch",
262 "err.npm_lock_graph_not_produced",
263 "err.npm_install_package_missing",
264 "err.npm_install_bin_dir_missing",
265 "err.npm_offline_lock_graph_required",
266 "err.npm_package_sri_missing",
267 "err.npm_dynamic_no_validated_executables",
268 "err.npm_dynamic_managed_node_required",
269 "err.managed_node_bin_dir_missing",
270 "err.npm_bin_outside_install_root",
271 "err.npm_bins_not_discovered",
272 "err.npm_bin_target_unresolved",
273 "err.npm_lock_payload_read",
274 "err.npm_lock_payload_not_utf8",
275 "err.npm_project_manifest_identity_mismatch",
276 "err.npm_project_manifest_build_policy_mismatch",
277 "err.npm_graph_parse_invalid",
278 "err.npm_graph_root_missing",
279 "err.npm_graph_root_version_mismatch",
280 "err.npm_graph_resolved_root_missing",
281 "err.npm_graph_root_integrity_missing",
282 "err.npm_graph_root_integrity_invalid",
283 "err.npm_graph_root_source_mismatch",
284 "err.lock_npm_legacy_inline_regenerate",
285 "err.lockfile_not_utf8",
286 "err.lock_schema2_npm_artifact_forbidden",
287 "err.lock_npm_package_mismatch",
288 "err.lock_npm_graph_format_unsupported",
289 "err.lock_npm_graph_path_unsafe_platform",
290 "err.lock_npm_node_entry_required",
291 "err.lock_npm_node_version_mismatch",
292 "err.lock_npm_graph_missing",
293 "err.lock_schema2_npm_legacy_inline",
294 "err.lock_non_npm_graph_metadata",
295 "err.lock_npm_graph_sha256_invalid",
296 "err.lock_npm_graph_path_unsafe",
297 "err.lock_npm_sidecar_read",
298 "err.lock_npm_sidecar_checksum_mismatch",
299 "err.lock_npm_sidecar_not_utf8",
300 "err.lock_npm_graph_path_symlink",
301 "err.lock_npm_sidecar_symlink",
302 "err.file_size_limit_exceeded",
303 "err.lock_backend_id_unsafe",
304 "err.lock_version_unsafe",
305 "err.lock_artifact_filename_unsafe",
306 "err.lock_artifact_subdir_unsafe",
307 "err.lock_schema1_npm_migration_requires_graph",
308 "err.lock_npm_resolved_node_required",
309 "err.lockfile_size_limit_exceeded",
310 "err.lock_atomic_path_filename_missing",
311 "err.fs_directory_create",
312 "err.fs_file_create",
313 "err.fs_file_write",
314 "err.fs_file_sync",
315 "err.fs_file_replace",
316 ] {
317 let en = trl(Lang::En, key);
318 let zh = trl(Lang::Zh, key);
319 assert_ne!(en, key, "missing en for {key}");
320 assert_ne!(zh, key, "missing zh for {key}");
321 assert_ne!(en, zh, "zh not translated for {key}");
322 assert_eq!(
323 placeholders(&en),
324 placeholders(&zh),
325 "placeholder mismatch for {key}"
326 );
327 }
328 }
329
330 fn placeholders(message: &str) -> Vec<&str> {
331 let mut placeholders = Vec::new();
332 let mut rest = message;
333 while let Some(open) = rest.find('{') {
334 rest = &rest[open + 1..];
335 let Some(close) = rest.find('}') else {
336 break;
337 };
338 placeholders.push(&rest[..close]);
339 rest = &rest[close + 1..];
340 }
341 placeholders.sort_unstable();
342 placeholders
343 }
344
345 #[test]
346 fn zh_falls_back_to_en_when_empty() {
347 let en = trl(Lang::En, "msg.installed");
349 assert!(!en.is_empty());
350 }
351}