1#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
12#[serde(rename_all = "lowercase")]
13#[derive(Default)]
14pub enum Lang {
15 #[default]
16 En,
17 Zh,
18}
19
20impl Lang {
21 pub fn parse(s: &str) -> Option<Self> {
22 let s = s.trim().to_ascii_lowercase();
23 if s.starts_with("zh") || s.starts_with("cmn") || s == "chinese" {
25 return Some(Self::Zh);
26 }
27 if s.starts_with("en") || s == "english" {
28 return Some(Self::En);
29 }
30 None
31 }
32
33 pub fn as_str(&self) -> &'static str {
36 match self {
37 Self::En => "en",
38 Self::Zh => "zh",
39 }
40 }
41
42 pub fn html_lang(&self) -> &'static str {
44 match self {
45 Self::En => "en",
46 Self::Zh => "zh-Hans",
47 }
48 }
49
50 pub fn resolve(explicit: Option<&str>, env: &dyn Fn(&str) -> Option<String>) -> Self {
54 if let Some(v) = explicit {
55 if let Some(l) = Self::parse(v) {
56 return l;
57 }
58 }
59 for key in ["LLM_VERIFY_LANG", "LC_ALL", "LC_MESSAGES", "LANG"] {
60 if let Some(v) = env(key) {
61 if v.is_empty() || v.starts_with('C') || v.starts_with("POSIX") {
63 continue;
64 }
65 if let Some(l) = Self::parse(&v) {
66 return l;
67 }
68 }
69 }
70 Self::En
71 }
72
73 pub fn from_env(explicit: Option<&str>) -> Self {
75 Self::resolve(explicit, &|k| std::env::var(k).ok())
76 }
77}
78
79#[macro_export]
89macro_rules! t {
90 ($lang:expr, $en:literal, $zh:literal) => {
96 match $lang {
97 $crate::i18n::Lang::En => format!($en),
98 $crate::i18n::Lang::Zh => format!($zh),
99 }
100 };
101 ($lang:expr, $en:literal, $zh:literal, $($arg:tt)*) => {
102 match $lang {
103 $crate::i18n::Lang::En => format!($en, $($arg)*),
104 $crate::i18n::Lang::Zh => format!($zh, $($arg)*),
105 }
106 };
107}
108
109#[macro_export]
112macro_rules! ts {
113 ($lang:expr, $en:literal, $zh:literal) => {
114 match $lang {
115 $crate::i18n::Lang::En => $en,
116 $crate::i18n::Lang::Zh => $zh,
117 }
118 };
119}
120
121#[cfg(test)]
122mod tests {
123 use super::*;
124 use std::collections::HashMap;
125
126 fn env_from(pairs: &[(&str, &str)]) -> impl Fn(&str) -> Option<String> + use<> {
127 let map: HashMap<String, String> = pairs
128 .iter()
129 .map(|(k, v)| (k.to_string(), v.to_string()))
130 .collect();
131 move |k: &str| map.get(k).cloned()
132 }
133
134 #[test]
135 fn parses_bare_tags_and_full_locales() {
136 assert_eq!(Lang::parse("zh"), Some(Lang::Zh));
137 assert_eq!(Lang::parse("zh_CN.UTF-8"), Some(Lang::Zh));
138 assert_eq!(Lang::parse("zh-Hant"), Some(Lang::Zh));
139 assert_eq!(Lang::parse("EN"), Some(Lang::En));
140 assert_eq!(Lang::parse("en_GB.UTF-8"), Some(Lang::En));
141 assert_eq!(Lang::parse("fr_FR"), None);
142 assert_eq!(Lang::parse(""), None);
143 }
144
145 #[test]
146 fn explicit_flag_wins_over_everything() {
147 let env = env_from(&[("LANG", "zh_CN.UTF-8"), ("LLM_VERIFY_LANG", "zh")]);
148 assert_eq!(Lang::resolve(Some("en"), &env), Lang::En);
149 }
150
151 #[test]
152 fn dedicated_variable_beats_the_system_locale() {
153 let env = env_from(&[("LLM_VERIFY_LANG", "en"), ("LANG", "zh_CN.UTF-8")]);
154 assert_eq!(Lang::resolve(None, &env), Lang::En);
155 }
156
157 #[test]
158 fn falls_back_to_the_system_locale() {
159 let env = env_from(&[("LANG", "zh_CN.UTF-8")]);
160 assert_eq!(Lang::resolve(None, &env), Lang::Zh);
161 let env = env_from(&[("LC_ALL", "zh_TW")]);
162 assert_eq!(Lang::resolve(None, &env), Lang::Zh);
163 }
164
165 #[test]
166 fn defaults_to_english_when_nothing_is_set_or_understood() {
167 assert_eq!(Lang::resolve(None, &env_from(&[])), Lang::En);
168 assert_eq!(
170 Lang::resolve(None, &env_from(&[("LANG", "de_DE.UTF-8")])),
171 Lang::En
172 );
173 let env = env_from(&[("LANG", "zh_CN.UTF-8")]);
175 assert_eq!(Lang::resolve(Some("klingon"), &env), Lang::Zh);
176 }
177
178 #[test]
179 fn the_c_locale_is_not_a_language_choice() {
180 assert_eq!(Lang::resolve(None, &env_from(&[("LC_ALL", "C")])), Lang::En);
181 assert_eq!(
182 Lang::resolve(None, &env_from(&[("LANG", "POSIX")])),
183 Lang::En
184 );
185 let env = env_from(&[("LC_ALL", "C"), ("LANG", "zh_CN.UTF-8")]);
187 assert_eq!(Lang::resolve(None, &env), Lang::Zh);
188 }
189
190 #[test]
191 fn empty_values_are_skipped_rather_than_matched() {
192 let env = env_from(&[("LLM_VERIFY_LANG", ""), ("LANG", "zh_CN.UTF-8")]);
193 assert_eq!(Lang::resolve(None, &env), Lang::Zh);
194 }
195
196 #[test]
197 fn t_macro_selects_and_formats() {
198 assert_eq!(t!(Lang::En, "hello", "你好"), "hello");
199 assert_eq!(t!(Lang::Zh, "hello", "你好"), "你好");
200 assert_eq!(t!(Lang::En, "{} ms", "{} 毫秒", 42), "42 ms");
201 assert_eq!(t!(Lang::Zh, "{} ms", "{} 毫秒", 42), "42 毫秒");
202 assert_eq!(t!(Lang::En, "{a}/{b}", "{a} 比 {b}", a = 1, b = 2), "1/2");
204 }
205
206 #[test]
207 fn ts_macro_borrows() {
208 let s: &'static str = ts!(Lang::Zh, "left", "左");
209 assert_eq!(s, "左");
210 }
211
212 #[test]
213 fn html_lang_is_a_valid_bcp47_tag() {
214 assert_eq!(Lang::En.html_lang(), "en");
215 assert_eq!(Lang::Zh.html_lang(), "zh-Hans");
216 }
217}
218
219#[cfg(test)]
220mod capture_tests {
221 use super::*;
222
223 #[test]
224 fn inline_named_capture_resolves_at_the_call_site() {
225 let host = "api.example.com";
230 let n = 7;
231 assert_eq!(
232 t!(Lang::En, "host {host} has {n}", "主机 {host} 有 {n}"),
233 "host api.example.com has 7"
234 );
235 assert_eq!(
236 t!(Lang::Zh, "host {host} has {n}", "主机 {host} 有 {n}"),
237 "主机 api.example.com 有 7"
238 );
239 }
240
241 #[test]
242 fn inline_capture_works_alongside_explicit_args() {
243 let name = "x";
244 assert_eq!(t!(Lang::En, "{name}={}", "{name}={}", 42), "x=42");
245 }
246}
247
248#[cfg(test)]
249mod coverage_tests {
250 #[test]
265 fn every_chinese_literal_has_an_english_partner() {
266 let mut offenders = Vec::new();
267 for file in source_files() {
268 let src = std::fs::read_to_string(&file).unwrap();
269 let body = strip_tests(&src);
270 for (pos, lit) in string_literals(&body) {
271 if !has_cjk(&lit) {
272 continue;
273 }
274 if !is_translated(&body, pos) {
275 offenders.push(format!(
276 "{}: {}",
277 file.file_name().unwrap().to_string_lossy(),
278 lit.chars().take(50).collect::<String>()
279 ));
280 }
281 }
282 }
283 assert!(
284 offenders.is_empty(),
285 "{} untranslated user-facing string(s):\n{}",
286 offenders.len(),
287 offenders.join("\n")
288 );
289 }
290
291 fn is_translated(src: &str, pos: usize) -> bool {
302 let before = src[..pos].trim_end();
303 if before.ends_with("=>") || before.ends_with('=') {
304 return true;
305 }
306 match enclosing_open(src, pos) {
307 Some((idx, b'(')) => {
308 let head = src[..idx].trim_end();
309 head.ends_with("t!") || head.ends_with("ts!") || inside_const_table(src, idx)
310 }
311 Some((_, b'[')) => true,
313 _ => false,
314 }
315 }
316
317 fn enclosing_open(src: &str, pos: usize) -> Option<(usize, u8)> {
319 let b = src.as_bytes();
320 let mut depth = 0i32;
321 let mut i = pos;
322 while i > 0 {
323 i -= 1;
324 match b[i] {
325 b')' | b']' | b'}' => depth += 1,
326 b'(' | b'[' | b'{' => {
327 if depth == 0 {
328 return Some((i, b[i]));
329 }
330 depth -= 1;
331 }
332 _ => {}
333 }
334 }
335 None
336 }
337
338 fn inside_const_table(src: &str, idx: usize) -> bool {
341 matches!(enclosing_open(src, idx), Some((_, b'[')))
342 }
343
344 fn source_files() -> Vec<std::path::PathBuf> {
345 let root = concat!(env!("CARGO_MANIFEST_DIR"), "/src");
346 let mut out = Vec::new();
347 for dir in [root.to_string(), format!("{root}/probes")] {
348 let Ok(entries) = std::fs::read_dir(&dir) else {
349 continue;
350 };
351 for e in entries.flatten() {
352 let p = e.path();
353 if p.extension().and_then(|x| x.to_str()) == Some("rs")
354 && p.file_name().and_then(|x| x.to_str()) != Some("i18n.rs")
356 {
357 out.push(p);
358 }
359 }
360 }
361 out
362 }
363
364 fn has_cjk(s: &str) -> bool {
369 s.chars().any(|c| {
370 ('\u{4e00}'..='\u{9fff}').contains(&c) || ('\u{3000}'..='\u{303f}').contains(&c) || ('\u{ff01}'..='\u{ff65}').contains(&c) })
374 }
375
376 fn strip_tests(src: &str) -> String {
378 match src.find("#[cfg(test)]") {
379 Some(i) => src[..i].to_string(),
380 None => src.to_string(),
381 }
382 }
383
384 fn string_literals(src: &str) -> Vec<(usize, String)> {
392 let b = src.as_bytes();
393 let mut out = Vec::new();
394 let mut i = 0;
395 while i < b.len() {
396 if b[i] == b'r' {
398 let mut j = i + 1;
399 let hash_start = j;
400 while j < b.len() && b[j] == b'#' {
401 j += 1;
402 }
403 if j < b.len() && b[j] == b'"' {
404 let hashes = j - hash_start;
405 let close = format!("\"{}", "#".repeat(hashes));
406 i = match src[j + 1..].find(&close) {
407 Some(k) => j + 1 + k + close.len(),
408 None => b.len(),
409 };
410 continue;
411 }
412 }
413 match b[i] {
414 b'/' if i + 1 < b.len() && b[i + 1] == b'/' => {
415 while i < b.len() && b[i] != b'\n' {
416 i += 1;
417 }
418 }
419 b'\'' => {
426 let mut j = i + 1;
427 if j < b.len() && b[j] == b'\\' {
428 j += 2;
429 } else {
430 j += 1;
432 while j < b.len() && (b[j] & 0xC0) == 0x80 {
433 j += 1;
434 }
435 }
436 i = if j < b.len() && b[j] == b'\'' {
437 j + 1
438 } else {
439 i + 1
440 };
441 }
442 b'"' => {
443 let start = i;
444 i += 1;
445 while i < b.len() && b[i] != b'"' {
446 i += if b[i] == b'\\' { 2 } else { 1 };
447 }
448 i += 1;
449 if let Some(s) = src.get(start..i.min(src.len())) {
450 out.push((start, s.to_string()));
451 }
452 }
453 _ => i += 1,
454 }
455 }
456 out
457 }
458}