Skip to main content

ui/
i18n_msg.rs

1// i18n_msg -- messages with named, numbered and plural placeholders (a subset of ICU
2// MessageFormat), so translations may reorder and inflect
3//
4//   msg(text, &[("n", 3.into()), ("dir", "Bilder".into())]) -> String
5//
6//   {name} {0} {}            an argument by name, by position, or the next one in order;
7//                            numbers as they are, with the locale's decimal sign ("1234,5")
8//   {n, number}              grouped ("1.234,5"); styles: integer (rounded), percent (a
9//                            fraction: 0.25 -> "25 %"), currency/EUR, size (bytes: "1,5 MB")
10//   {d, date} {d, date, long} {d, time} {d, datetime}   an Arg::Date (datetime::Civil)
11//   {n, plural, =0 {keine Dateien} one {# Datei} other {# Dateien}}
12//                            the CLDR category of the UI language (language_code()); =N
13//                            matches before the categories; # is the number (grouped);
14//                            "offset:1" before the options subtracts 1 for # and the category
15//   {g, select, female {sie} male {er} other {es}}
16//   '{' '}' '#' quote,  '' is an apostrophe; a lone apostrophe stays ("geht's")
17//
18// The numbers follow the user's format (locale()).  A missing argument prints as {name};
19// broken syntax prints the rest of the text as it is.
20
21use alloc::format;
22use alloc::string::String;
23use alloc::vec::Vec;
24use datetime::Civil;
25
26use super::{DATE_LONG, DATE_SHORT, Locale, language_code, locale, plural};
27
28/// one argument of msg()
29#[derive(Clone, Copy, Debug)]
30#[non_exhaustive]
31pub enum Arg<'a> {
32    Str(&'a str),
33    Int(i64),
34    Num(f64),
35    Date(Civil),
36}
37
38impl<'a> From<&'a str> for Arg<'a> {
39    fn from(s: &'a str) -> Self {
40        Arg::Str(s)
41    }
42}
43impl<'a> From<&'a String> for Arg<'a> {
44    fn from(s: &'a String) -> Self {
45        Arg::Str(s)
46    }
47}
48impl From<i64> for Arg<'_> {
49    fn from(n: i64) -> Self {
50        Arg::Int(n)
51    }
52}
53impl From<i32> for Arg<'_> {
54    fn from(n: i32) -> Self {
55        Arg::Int(n as i64)
56    }
57}
58impl From<u32> for Arg<'_> {
59    fn from(n: u32) -> Self {
60        Arg::Int(n as i64)
61    }
62}
63impl From<u64> for Arg<'_> {
64    fn from(n: u64) -> Self {
65        Arg::Int(n.min(i64::MAX as u64) as i64)
66    }
67}
68impl From<usize> for Arg<'_> {
69    fn from(n: usize) -> Self {
70        Arg::Int(n.min(i64::MAX as usize) as i64)
71    }
72}
73impl From<f64> for Arg<'_> {
74    fn from(v: f64) -> Self {
75        Arg::Num(v)
76    }
77}
78impl From<f32> for Arg<'_> {
79    fn from(v: f32) -> Self {
80        Arg::Num(v as f64)
81    }
82}
83impl From<Civil> for Arg<'_> {
84    fn from(c: Civil) -> Self {
85        Arg::Date(c)
86    }
87}
88
89struct Cx<'a, 'b> {
90    args: &'b [(&'b str, Arg<'a>)],
91    loc: Locale,
92    lang: String,
93}
94
95/// format a message (see the module comment)
96pub fn msg(text: &str, args: &[(&str, Arg)]) -> String {
97    let s: Vec<char> = text.chars().collect();
98    let cx = Cx { args, loc: locale(), lang: language_code() };
99    let mut out = String::with_capacity(text.len() + 16);
100    let (mut p, mut seq) = (0, 0);
101    run(&s, &mut p, &cx, &mut seq, None, false, &mut out);
102    out
103}
104
105// text up to the end (or, nested, up to the '}' that closes it, which is not consumed)
106fn run(s: &[char], p: &mut usize, cx: &Cx, seq: &mut usize, hash: Option<&str>, nested: bool, out: &mut String) {
107    while *p < s.len() {
108        let c = s[*p];
109        match c {
110            '\'' => quote(s, p, hash.is_some(), out),
111            '{' => {
112                let start = *p;
113                *p += 1;
114                if arg(s, p, cx, seq, out).is_none() {
115                    out.extend(&s[start..]);
116                    *p = s.len();
117                }
118            }
119            '}' if nested => return,
120            '#' if hash.is_some() => {
121                out.push_str(hash.unwrap_or(""));
122                *p += 1;
123            }
124            _ => {
125                out.push(c);
126                *p += 1;
127            }
128        }
129    }
130}
131
132// an apostrophe: '' -> ', '{...' quoted up to the next lone apostrophe, else itself
133fn quote(s: &[char], p: &mut usize, in_plural: bool, out: &mut String) {
134    let next = s.get(*p + 1).copied();
135    if next == Some('\'') {
136        out.push('\'');
137        *p += 2;
138        return;
139    }
140    if !(matches!(next, Some('{' | '}')) || (in_plural && next == Some('#'))) {
141        out.push('\'');
142        *p += 1;
143        return;
144    }
145    *p += 1;
146    while *p < s.len() {
147        if s[*p] == '\'' {
148            if s.get(*p + 1) == Some(&'\'') {
149                out.push('\'');
150                *p += 2;
151                continue;
152            }
153            *p += 1;
154            return;
155        }
156        out.push(s[*p]);
157        *p += 1;
158    }
159}
160
161fn skip_ws(s: &[char], p: &mut usize) {
162    while *p < s.len() && s[*p].is_whitespace() {
163        *p += 1;
164    }
165}
166
167// a word up to one of the stop characters, trimmed
168fn word(s: &[char], p: &mut usize, stop: &[char]) -> String {
169    let start = *p;
170    while *p < s.len() && !stop.contains(&s[*p]) {
171        *p += 1;
172    }
173    let w: String = s[start..*p].iter().collect();
174    String::from(w.trim())
175}
176
177// the argument after '{' up to and with its '}' -> None on broken syntax
178fn arg(s: &[char], p: &mut usize, cx: &Cx, seq: &mut usize, out: &mut String) -> Option<()> {
179    let name = word(s, p, &[',', '}']);
180    let value = lookup(cx, &name, seq);
181    if s.get(*p) == Some(&'}') {
182        *p += 1;
183        match value {
184            Some(v) => out.push_str(&plain(cx, v)),
185            None => out.push_str(&format!("{{{name}}}")),
186        }
187        return Some(());
188    }
189    *p += 1; // ','
190    let typ = word(s, p, &[',', '}']);
191    let style = if s.get(*p) == Some(&',') {
192        *p += 1;
193        match typ.as_str() {
194            "plural" | "select" => None,
195            _ => Some(word(s, p, &['}'])),
196        }
197    } else {
198        Some(String::new())
199    };
200    match typ.as_str() {
201        "plural" | "select" if style.is_none() => {
202            let opts = options(s, p)?;
203            let n = value.map(|v| number(cx, v));
204            let branch = if typ == "plural" { choose_plural(cx, &opts, n) } else { choose_select(&opts, value) }?;
205            let hash = if typ == "plural" { n.map(|(x, digits)| hash_text(cx, x - opts.offset, digits)) } else { None };
206            let (mut q, mut inner_seq) = (branch.0, *seq);
207            let mut sub = String::new();
208            run(&s[..branch.1], &mut q, cx, &mut inner_seq, hash.as_deref(), false, &mut sub);
209            out.push_str(&sub);
210            Some(())
211        }
212        _ => {
213            if s.get(*p) != Some(&'}') {
214                return None;
215            }
216            *p += 1;
217            let style = style.unwrap_or_default();
218            match value {
219                Some(v) => out.push_str(&typed(cx, v, &typ, &style)),
220                None => out.push_str(&format!("{{{name}}}")),
221            }
222            Some(())
223        }
224    }
225}
226
227fn lookup<'a>(cx: &Cx<'a, '_>, name: &str, seq: &mut usize) -> Option<Arg<'a>> {
228    if name.is_empty() {
229        *seq += 1;
230        return cx.args.get(*seq - 1).map(|a| a.1);
231    }
232    if name.bytes().all(|b| b.is_ascii_digit()) {
233        return name.parse::<usize>().ok().and_then(|k| cx.args.get(k)).map(|a| a.1);
234    }
235    cx.args.iter().find(|a| a.0 == name).map(|a| a.1)
236}
237
238// "offset:N"? then "selector {message}" pairs up to the closing '}' -> (offset, [(selector,
239// start, end)]); the message is s[start..end]
240struct Opts {
241    offset: f64,
242    list: Vec<(String, usize, usize)>,
243}
244
245fn options(s: &[char], p: &mut usize) -> Option<Opts> {
246    let mut o = Opts { offset: 0.0, list: Vec::new() };
247    loop {
248        skip_ws(s, p);
249        match s.get(*p) {
250            None => return None,
251            Some('}') => {
252                *p += 1;
253                return Some(o);
254            }
255            _ => {}
256        }
257        let sel = word(s, p, &['{', ' ', '\t', '\r', '\n', '}']);
258        if let Some(n) = sel.strip_prefix("offset:") {
259            o.offset = n.trim().parse().ok()?;
260            continue;
261        }
262        skip_ws(s, p);
263        if sel.is_empty() || s.get(*p) != Some(&'{') {
264            return None;
265        }
266        *p += 1;
267        let start = *p;
268        close(s, p)?;
269        o.list.push((sel, start, *p - 1));
270    }
271}
272
273// past the '}' that closes a message (nesting and quotes respected)
274fn close(s: &[char], p: &mut usize) -> Option<()> {
275    let mut depth = 0;
276    while *p < s.len() {
277        match s[*p] {
278            '\'' => {
279                let mut sink = String::new();
280                quote(s, p, true, &mut sink);
281                continue;
282            }
283            '{' => depth += 1,
284            '}' if depth == 0 => {
285                *p += 1;
286                return Some(());
287            }
288            '}' => depth -= 1,
289            _ => {}
290        }
291        *p += 1;
292    }
293    None
294}
295
296// a numeric argument -> (value, visible fraction digits)
297fn number(cx: &Cx, v: Arg) -> (f64, u32) {
298    match v {
299        Arg::Int(n) => (n as f64, 0),
300        Arg::Num(x) => {
301            let s = cx.loc.num_plain(x);
302            (x, s.split(cx.loc.decimal()).nth(1).map_or(0, |f| f.chars().count() as u32))
303        }
304        Arg::Str(t) => {
305            let t = t.trim();
306            let digits = t.split(['.', ',']).nth(1).map_or(0, |f| f.len() as u32);
307            (t.replace(',', ".").parse().unwrap_or(f64::NAN), digits)
308        }
309        Arg::Date(_) => (f64::NAN, 0),
310    }
311}
312
313fn hash_text(cx: &Cx, x: f64, digits: u32) -> String {
314    if x.is_nan() { String::new() } else { cx.loc.fmt_num(x, digits) }
315}
316
317fn choose_plural(cx: &Cx, o: &Opts, n: Option<(f64, u32)>) -> Option<(usize, usize)> {
318    let pick = |name: &str| o.list.iter().find(|e| e.0 == name).map(|e| (e.1, e.2));
319    if let Some((x, digits)) = n {
320        // =N on the value itself
321        if let Some(e) = o.list.iter().find(|e| e.0.strip_prefix('=').and_then(|v| v.parse::<f64>().ok()) == Some(x)) {
322            return Some((e.1, e.2));
323        }
324        let y = x - o.offset;
325        if !y.is_nan() {
326            let a = if y < 0.0 { -y } else { y };
327            let (i, v, f) = operands(cx, a, digits);
328            if let Some(b) = pick(plural(&cx.lang, i, v, f).name()) {
329                return Some(b);
330            }
331        }
332    }
333    pick("other")
334}
335
336// CLDR operands of a non-negative number shown with `digits` fraction digits
337fn operands(cx: &Cx, a: f64, digits: u32) -> (u64, u32, u64) {
338    let s = cx.loc.num(a, digits, false);
339    let (int, frac) = s.split_once(cx.loc.decimal()).unwrap_or((&s, ""));
340    (int.parse().unwrap_or(u64::MAX), frac.len() as u32, frac.parse().unwrap_or(0))
341}
342
343fn choose_select(o: &Opts, v: Option<Arg>) -> Option<(usize, usize)> {
344    let key = match v {
345        Some(Arg::Str(s)) => Some(String::from(s)),
346        Some(Arg::Int(n)) => Some(format!("{n}")),
347        _ => None,
348    };
349    key.and_then(|k| o.list.iter().find(|e| e.0 == k)).or_else(|| o.list.iter().find(|e| e.0 == "other")).map(|e| (e.1, e.2))
350}
351
352// {x}: as it is
353fn plain(cx: &Cx, v: Arg) -> String {
354    match v {
355        Arg::Str(s) => String::from(s),
356        Arg::Int(n) => format!("{n}"),
357        Arg::Num(x) => cx.loc.num_plain(x),
358        Arg::Date(c) => cx.loc.fmt_date(&c, DATE_SHORT),
359    }
360}
361
362// {x, number|date|time|datetime, style}
363fn typed(cx: &Cx, v: Arg, typ: &str, style: &str) -> String {
364    let style = style.trim().trim_start_matches("::");
365    match (typ, v) {
366        ("date", Arg::Date(c)) => cx.loc.fmt_date(&c, if style == "long" || style == "full" { DATE_LONG } else { DATE_SHORT }),
367        ("time", Arg::Date(c)) => cx.loc.fmt_time(&c),
368        ("datetime", Arg::Date(c)) => cx.loc.fmt_date_time(&c),
369        ("number", Arg::Int(_) | Arg::Num(_) | Arg::Str(_)) => {
370            let (x, digits) = number(cx, v);
371            if x.is_nan() {
372                return plain(cx, v);
373            }
374            if style == "integer" {
375                cx.loc.fmt_num(x, 0)
376            } else if style == "percent" {
377                cx.loc.fmt_percent(x, 0)
378            } else if let Some(code) = style.strip_prefix("currency/") {
379                cx.loc.fmt_currency(x, code.trim())
380            } else if style == "size" {
381                cx.loc.fmt_size(if x > 0.0 { x as u64 } else { 0 })
382            } else {
383                cx.loc.fmt_num(x, digits)
384            }
385        }
386        _ => plain(cx, v),
387    }
388}