1use serde_json::Value;
12use std::sync::OnceLock;
13
14#[derive(Clone, Copy, Debug, PartialEq, Eq)]
16pub enum Locale {
17 Zh,
18 En,
19}
20
21impl Locale {
22 fn code(self) -> &'static str {
23 match self {
24 Locale::Zh => "zh",
25 Locale::En => "en",
26 }
27 }
28}
29
30#[derive(Clone, Debug, PartialEq)]
32pub enum Param<'a> {
33 Str(&'a str),
34 Int(i64),
35 Num(f64),
36 Bool(bool),
37}
38
39impl Param<'_> {
40 fn render(&self) -> String {
41 match self {
42 Param::Str(s) => (*s).to_string(),
43 Param::Int(n) => n.to_string(),
44 Param::Num(n) => js_number(*n),
45 Param::Bool(b) => b.to_string(),
46 }
47 }
48}
49
50fn catalogue(locale: Locale) -> &'static Value {
51 static ZH: OnceLock<Value> = OnceLock::new();
52 static EN: OnceLock<Value> = OnceLock::new();
53 let (cell, text) = match locale {
54 Locale::Zh => (&ZH, include_str!("../../catalog/zh.json")),
55 Locale::En => (&EN, include_str!("../../catalog/en.json")),
56 };
57 cell.get_or_init(|| serde_json::from_str(text).expect("embedded catalogue is valid JSON"))
58}
59
60pub fn lookup(locale: Locale, key: &str) -> Option<&'static str> {
62 find(catalogue(locale), key).and_then(Value::as_str)
63}
64
65pub fn format(locale: Locale, key: &str, params: &[(&str, Param)]) -> String {
69 format_in(
70 catalogue(locale),
71 catalogue(Locale::Zh),
72 locale,
73 key,
74 params,
75 )
76}
77
78pub fn display_name(locale: Locale, name: &str) -> String {
84 if locale == Locale::Zh {
85 return name.to_string();
86 }
87 let canonical = |key: &str| {
88 lookup(Locale::Zh, &format!("names.{key}")).expect("the catalogue has the names section")
89 };
90 let mut suffixes = Vec::new();
91 let mut rest = name;
92 if let Some(base) = rest
93 .strip_suffix(canonical("framed"))
94 .and_then(|r| r.strip_suffix(NAME_SEPARATOR))
95 {
96 rest = base;
97 suffixes.push(format(locale, "names.framed", &[]));
98 }
99 if let Some((base, tail)) = rest.rsplit_once(NAME_SEPARATOR) {
100 if let Some(n) = numbered(canonical("fragment"), tail) {
101 rest = base;
102 suffixes.insert(0, format(locale, "names.fragment", &[("n", Param::Str(n))]));
103 }
104 }
105 let base = ["fixed", "divider", "unassigned", "ignored"]
106 .into_iter()
107 .find(|key| canonical(key) == rest)
108 .map(|key| format(locale, &format!("names.{key}"), &[]))
109 .or_else(|| {
110 ["contentCanvas", "contentRegion"]
111 .into_iter()
112 .find_map(|key| {
113 numbered(canonical(key), rest)
114 .map(|n| format(locale, &format!("names.{key}"), &[("n", Param::Str(n))]))
115 })
116 })
117 .unwrap_or_else(|| rest.to_string());
118 std::iter::once(base)
119 .chain(suffixes)
120 .collect::<Vec<_>>()
121 .join(NAME_SEPARATOR)
122}
123
124const NAME_SEPARATOR: &str = " · ";
126
127fn numbered<'a>(template: &str, name: &'a str) -> Option<&'a str> {
129 let (before, after) = template.split_once("{{n}}")?;
130 let n = name.strip_prefix(before)?.strip_suffix(after)?;
131 (!n.is_empty() && n.bytes().all(|b| b.is_ascii_digit())).then_some(n)
132}
133
134fn find<'v>(root: &'v Value, key: &str) -> Option<&'v Value> {
135 key.split('.').try_fold(root, |node, part| node.get(part))
136}
137
138fn format_in(
139 primary: &Value,
140 fallback: &Value,
141 locale: Locale,
142 key: &str,
143 params: &[(&str, Param)],
144) -> String {
145 match find(primary, key).or_else(|| find(fallback, key)) {
146 Some(Value::String(text)) => interpolate(text, params),
147 Some(Value::Object(_)) => format!(
149 "key '{key} ({})' returned an object instead of string.",
150 locale.code()
151 ),
152 _ => key.to_string(),
153 }
154}
155
156const MAX_REPLACES: usize = 1000;
158
159fn interpolate(text: &str, params: &[(&str, Param)]) -> String {
164 let mut out = text.to_string();
165 let mut cursor = 0;
166 for _ in 0..MAX_REPLACES {
167 let Some((start, end)) = next_placeholder(&out, cursor) else {
168 break;
169 };
170 let placeholder = out[start..end].to_string();
171 let name = placeholder[2..placeholder.len() - 2].trim();
172 let Some((_, value)) = params.iter().find(|(n, _)| *n == name) else {
173 cursor = end;
174 continue;
175 };
176 let value = value.render();
177 let first = out
178 .find(&placeholder)
179 .expect("the placeholder was just found");
180 out.replace_range(first..first + placeholder.len(), &value);
181 cursor = start + value.len();
182 }
183 out
184}
185
186fn next_placeholder(text: &str, from: usize) -> Option<(usize, usize)> {
189 let bytes = text.as_bytes();
190 let mut start = from;
191 while start + 5 <= bytes.len() {
192 if bytes[start] == b'{' && bytes[start + 1] == b'{' {
193 let body = &text[start + 2..];
194 let mut offset = 0;
195 for (i, c) in body.char_indices() {
196 if matches!(c, '\n' | '\r' | '\u{2028}' | '\u{2029}') {
197 break;
198 }
199 if i > 0 && body[i..].starts_with("}}") {
200 offset = i;
201 break;
202 }
203 }
204 if offset > 0 {
205 return Some((start, start + 2 + offset + 2));
206 }
207 }
208 start += 1;
209 while start < bytes.len() && !text.is_char_boundary(start) {
210 start += 1;
211 }
212 }
213 None
214}
215
216pub fn js_number(value: f64) -> String {
219 if value.is_nan() {
220 return "NaN".into();
221 }
222 if value.is_infinite() {
223 return if value > 0.0 { "Infinity" } else { "-Infinity" }.into();
224 }
225 if value == 0.0 {
226 return "0".into();
227 }
228 let sign = if value < 0.0 { "-" } else { "" };
229 let scientific = format!("{:e}", value.abs());
231 let (mantissa, exponent) = scientific
232 .split_once('e')
233 .expect("`{:e}` always has an exponent");
234 let digits: String = mantissa.chars().filter(|c| *c != '.').collect();
235 let k = digits.len() as i32;
236 let n = exponent
238 .parse::<i32>()
239 .expect("`{:e}` exponent is an integer")
240 + 1;
241 let body = if k <= n && n <= 21 {
242 format!("{digits}{}", "0".repeat((n - k) as usize))
243 } else if 0 < n && n <= 21 {
244 format!("{}.{}", &digits[..n as usize], &digits[n as usize..])
245 } else if -6 < n && n <= 0 {
246 format!("0.{}{digits}", "0".repeat((-n) as usize))
247 } else {
248 let e = n - 1;
249 let e = if e >= 0 {
250 format!("+{e}")
251 } else {
252 e.to_string()
253 };
254 if k == 1 {
255 format!("{digits}e{e}")
256 } else {
257 format!("{}.{}e{e}", &digits[..1], &digits[1..])
258 }
259 };
260 format!("{sign}{body}")
261}
262
263#[cfg(test)]
264mod tests {
265 use super::*;
266 use serde_json::json;
267
268 fn sample() -> (Value, Value) {
269 let zh = json!({
270 "a": "x={{x}} y={{y}}!",
271 "only_zh": "仅中文 {{n}}",
272 "section": { "leaf": "n {{n}}" },
273 "spaced": "k {{ k }} {{k}}",
274 "twice": "{{x}}{{x}}",
275 "broken": "{{a\nb}} {{c}}",
276 "empty": "{{}} {{c}}",
277 });
278 let en = json!({ "a": "en x={{x}}", "section": { "leaf": "en n {{n}}" } });
279 (zh, en)
280 }
281
282 fn zh(key: &str, params: &[(&str, Param)]) -> String {
283 let (zh, _) = sample();
284 format_in(&zh, &zh, Locale::Zh, key, params)
285 }
286
287 #[test]
288 fn missing_key_comes_back_as_the_key() {
289 assert_eq!(zh("no.such.key", &[]), "no.such.key");
290 assert_eq!(zh("a.deeper", &[]), "a.deeper");
291 }
292
293 #[test]
294 fn a_section_key_reads_like_i18next() {
295 assert_eq!(
296 zh("section", &[]),
297 "key 'section (zh)' returned an object instead of string."
298 );
299 }
300
301 #[test]
302 fn missing_param_leaves_the_placeholder() {
303 assert_eq!(zh("a", &[("x", Param::Int(1))]), "x=1 y={{y}}!");
304 assert_eq!(zh("section.leaf", &[]), "n {{n}}");
305 }
306
307 #[test]
308 fn en_falls_back_to_zh_per_key() {
309 let (zh, en) = sample();
310 assert_eq!(
311 format_in(&en, &zh, Locale::En, "a", &[("x", Param::Int(2))]),
312 "en x=2"
313 );
314 assert_eq!(
315 format_in(&en, &zh, Locale::En, "only_zh", &[("n", Param::Int(3))]),
316 "仅中文 3"
317 );
318 assert_eq!(format_in(&en, &zh, Locale::En, "nowhere", &[]), "nowhere");
319 }
320
321 #[test]
322 fn numbers_render_like_javascript() {
323 let cases: [(f64, &str); 16] = [
324 (1.0, "1"),
325 (1.5, "1.5"),
326 (2.50, "2.5"),
327 (0.1 + 0.2, "0.30000000000000004"),
328 (1e21, "1e+21"),
329 (1e20, "100000000000000000000"),
330 (1e-7, "1e-7"),
331 (1e-6, "0.000001"),
332 (1.23e-18, "1.23e-18"),
333 (-0.0, "0"),
334 (-12.25, "-12.25"),
335 (f64::INFINITY, "Infinity"),
336 (f64::NEG_INFINITY, "-Infinity"),
337 (f64::NAN, "NaN"),
338 (123456789012.0, "123456789012"),
339 (1.5e300, "1.5e+300"),
340 ];
341 for (value, expected) in cases {
342 assert_eq!(js_number(value), expected, "{value:?}");
343 assert_eq!(
344 zh("section.leaf", &[("n", Param::Num(value))]),
345 format!("n {expected}")
346 );
347 }
348 assert_eq!(zh("section.leaf", &[("n", Param::Int(-7))]), "n -7");
349 assert_eq!(zh("section.leaf", &[("n", Param::Bool(true))]), "n true");
350 }
351
352 #[test]
353 fn substitution_follows_i18next_first_occurrence_rule() {
354 assert_eq!(
356 zh("a", &[("x", Param::Str("{{y}}")), ("y", Param::Str("Y"))]),
357 "x=Y y={{y}}!"
358 );
359 assert_eq!(zh("spaced", &[("k", Param::Int(7))]), "k 7 7");
360 assert_eq!(zh("twice", &[("x", Param::Str("Q"))]), "QQ");
361 assert_eq!(
362 zh("a", &[("x", Param::Str("$&")), ("y", Param::Str("a$1b"))]),
363 "x=$& y=a$1b!"
364 );
365 }
366
367 #[test]
368 fn placeholders_need_a_name_on_one_line() {
369 assert_eq!(
370 zh("broken", &[("c", Param::Int(1)), ("a\nb", Param::Int(2))]),
371 "{{a\nb}} 1"
372 );
373 assert_eq!(zh("empty", &[("c", Param::Int(1))]), "{{}} {{c}}");
375 assert_eq!(zh("empty", &[("}} {{c", Param::Int(1))]), "1");
376 }
377
378 #[test]
379 fn real_catalogues_are_embedded() {
380 assert_eq!(
381 lookup(Locale::Zh, "progress.scanFrame"),
382 Some("一帧一帧看录屏,找出哪些区域在动。")
383 );
384 assert!(lookup(Locale::En, "progress.scanFrame").is_some());
385 assert!(lookup(Locale::Zh, "diag.MODEL_ASSUMPTIONS").is_none());
386 let message = format(
387 Locale::Zh,
388 "diag.MEMORY_BUDGET_RAISED.message",
389 &[
390 ("from", Param::Int(12)),
391 ("to", Param::Int(30)),
392 ("mb", Param::Int(9)),
393 ],
394 );
395 assert_eq!(
396 message,
397 "瓦片缓存从预算允许的 12 块提高到 30 块(约 9 MB),以容纳一帧触及的全部瓦片。"
398 );
399 }
400
401 #[test]
407 fn names_stay_as_stored_in_zh() {
408 for name in [
409 "内容画布 1",
410 "内容画布 1 · 未定位片段 2 · 保留外框",
411 "固定界面",
412 "",
413 ] {
414 assert_eq!(display_name(Locale::Zh, name), name);
415 }
416 }
417
418 #[test]
419 fn the_vocabulary_reads_in_english() {
420 let cases = [
421 ("固定界面", "Fixed UI"),
422 ("固定分隔界面", "Fixed divider"),
423 (
424 "未指定区域 · 屏幕坐标观察",
425 "Unassigned area · screen-space observations",
426 ),
427 ("主动忽略", "Ignored"),
428 ("内容画布 1", "Content canvas 1"),
429 ("内容画布 12", "Content canvas 12"),
430 ("内容画布 01", "Content canvas 01"),
431 ("内容区域 3", "Content region 3"),
432 ("内容画布 1 · 保留外框", "Content canvas 1 · framed"),
433 (
434 "内容画布 2 · 未定位片段 3",
435 "Content canvas 2 · unplaced fragment 3",
436 ),
437 (
438 "内容画布 1 · 未定位片段 2 · 保留外框",
439 "Content canvas 1 · unplaced fragment 2 · framed",
440 ),
441 (
442 "未指定区域 · 屏幕坐标观察 · 未定位片段 1",
443 "Unassigned area · screen-space observations · unplaced fragment 1",
444 ),
445 ];
446 for (name, english) in cases {
447 assert_eq!(display_name(Locale::En, name), english, "{name}");
448 }
449 }
450
451 #[test]
452 fn names_outside_the_vocabulary_keep_what_they_can() {
453 let cases = [
454 ("Header", "Header"),
455 ("", ""),
456 ("内容画布 x", "内容画布 x"),
457 ("内容画布 ", "内容画布 "),
458 ("内容画布 ١", "内容画布 ١"),
459 ("内容画布 1 extra", "内容画布 1 extra"),
460 ("内容画布 1 · 未定位片段 x", "内容画布 1 · 未定位片段 x"),
461 (
463 "内容画布 1 · 保留外框 · 未定位片段 2",
464 "内容画布 1 · 保留外框 · unplaced fragment 2",
465 ),
466 ("Header · 未定位片段 1", "Header · unplaced fragment 1"),
467 ("保留外框", "保留外框"),
468 ];
469 for (name, english) in cases {
470 assert_eq!(display_name(Locale::En, name), english, "{name}");
471 }
472 }
473}