Skip to main content

llm_verify/
i18n.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Bilingual output.
3//!
4//! The catalogue is deliberately *inline* rather than a separate resource
5//! file: every message is written at the point it is used, with both languages
6//! side by side, so a translation cannot silently drift away from the code
7//! that produces it. `cargo build` fails if one half is missing.
8
9/// Output language. English is the default because the project's public face
10/// is English; Chinese is reachable explicitly or via the system locale.
11#[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        // Accept both bare tags and full locale strings such as `zh_CN.UTF-8`.
24        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    /// Short tag, used wherever the language must be stated as data rather
34    /// than rendered — the report footer and the `--lang` echo.
35    pub fn as_str(&self) -> &'static str {
36        match self {
37            Self::En => "en",
38            Self::Zh => "zh",
39        }
40    }
41
42    /// BCP 47 tag for the report's `lang` attribute.
43    pub fn html_lang(&self) -> &'static str {
44        match self {
45            Self::En => "en",
46            Self::Zh => "zh-Hans",
47        }
48    }
49
50    /// Resolve from, in order: an explicit flag, `LLM_VERIFY_LANG`, then the
51    /// usual locale variables. Anything unrecognised falls through to English
52    /// rather than guessing.
53    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                // `C` and `POSIX` are "no locale set", not a language choice.
62                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    /// Production resolver reading the real process environment.
74    pub fn from_env(explicit: Option<&str>) -> Self {
75        Self::resolve(explicit, &|k| std::env::var(k).ok())
76    }
77}
78
79/// Pick between an English and a Chinese message, applying format arguments to
80/// whichever is selected.
81///
82/// ```ignore
83/// t!(lang, "Endpoint reachable, {}ms", "端点可达,{}ms", ms)
84/// ```
85///
86/// Both literals take the same arguments, so a mismatched placeholder count is
87/// a compile error rather than a runtime surprise.
88#[macro_export]
89macro_rules! t {
90    // Still `format!`, even with no trailing arguments: the messages use
91    // inline captures such as `{host}` heavily, and `.to_string()` would emit
92    // those braces literally instead of interpolating them. A literal brace in
93    // a message must therefore be written `{{`, which `format!` enforces at
94    // compile time.
95    ($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/// Like [`t!`] but yields a `&'static str`, for cases that must not allocate
110/// or that feed APIs expecting a borrowed string.
111#[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        // An unrecognised language must not become Chinese by accident.
169        assert_eq!(
170            Lang::resolve(None, &env_from(&[("LANG", "de_DE.UTF-8")])),
171            Lang::En
172        );
173        // An unparseable explicit value falls through to the next source.
174        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        // ...and must not mask a real setting further down the list.
186        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        // Named and positional arguments both work.
203        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        // `t!` expands to `format!($literal, ...)` inside the macro body. If
226        // macro hygiene stopped format!'s implicit capture from seeing the
227        // caller's bindings, every `{name}` in a message would silently render
228        // wrong — and there are many of them.
229        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    /// Every user-facing string must exist in both languages.
251    ///
252    /// This is a source-level check because the failure it guards against is
253    /// invisible at runtime in one language: `contract.rs` once shipped a full
254    /// set of Chinese probe labels with no English half, and an English run
255    /// silently printed Chinese for a third of its probes.
256    ///
257    /// The rule is structural rather than line-based, because `cargo fmt`
258    /// freely splits a `t!` call across lines. A Chinese literal must be
259    /// immediately preceded by one of:
260    ///   `,` + an English literal — its `t!`/`ts!` partner;
261    ///   `(`                      — a lookup-table entry carrying both halves;
262    ///   `=>`                     — an explicit per-language match arm;
263    ///   `=`                      — a named per-language constant.
264    #[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    /// A Chinese literal is legitimate only in one of four positions:
292    ///
293    ///   inside a `t!(` / `ts!(` call — its English half is the argument before;
294    ///   inside a `const` lookup table — the entry's other field carries English;
295    ///   after `=>` — an explicit per-language match arm;
296    ///   after `=` — a named per-language constant such as `ZH_BODY`.
297    ///
298    /// Checking the *enclosing call* rather than merely the previous literal
299    /// matters: `ProbeResult::new("jitter", "延迟抖动", G)` also has an English
300    /// literal in front of it, and an earlier version of this test passed it.
301    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            // Directly inside a `[...]` — a table row written without a tuple.
312            Some((_, b'[')) => true,
313            _ => false,
314        }
315    }
316
317    /// Byte index and kind of the innermost unclosed delimiter before `pos`.
318    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    /// Whether a tuple at `idx` sits inside a `const NAME: &[...] = &[` table,
339    /// where each row carries both languages as separate fields.
340    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                    // i18n.rs holds this test's own sample strings.
355                    && 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    /// Ideographs *and* CJK punctuation. The punctuation half matters: a
365    /// separator literal such as `"、"` or `":"` carries no ideograph, so a
366    /// bare `format!("{}:{}", ..)` used to slip past this check and print
367    /// full-width punctuation into English reports.
368    fn has_cjk(s: &str) -> bool {
369        s.chars().any(|c| {
370            ('\u{4e00}'..='\u{9fff}').contains(&c)      // ideographs
371                || ('\u{3000}'..='\u{303f}').contains(&c) // 、。〈〉《》 …
372                || ('\u{ff01}'..='\u{ff65}').contains(&c) // :()!? …
373        })
374    }
375
376    /// Drop `#[cfg(test)]` modules — fixtures are allowed to be monolingual.
377    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    /// Byte offsets and contents of every string literal, skipping line
385    /// comments and **raw** strings.
386    ///
387    /// Raw strings in this crate are HTML templates and the two per-language
388    /// skill bodies — neither is ever a `t!` argument, and treating a raw
389    /// string's opening `r#"` as a plain quote made the lookback see `r#`
390    /// instead of the `=` that marks a named constant.
391    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            // Raw string: `r`, any number of `#`, then a quote.
397            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                // A char literal, which may itself be a quote. `'"'` in
420                // `trim_matches('"')` once opened a phantom string that ran to
421                // the next quote hundreds of lines later, and every message in
422                // between went unchecked — that is how an untranslated error
423                // string survived in `main.rs`. Lifetimes (`'a`) are not char
424                // literals, so only advance when a closing quote is really there.
425                b'\'' => {
426                    let mut j = i + 1;
427                    if j < b.len() && b[j] == b'\\' {
428                        j += 2;
429                    } else {
430                        // One UTF-8 scalar, however many bytes it occupies.
431                        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}