Skip to main content

ferrovue_i18n/
lib.rs

1//! vue-i18n's `t()`, for components that call `$t` or `useI18n().t`: part of
2//! [ferrovue](https://docs.rs/ferrovue)'s runtime.
3//!
4//! # Example
5//!
6//! ```
7//! # mod ferrovue { pub use ferrovue_i18n::I18n; pub use ferrovue_i18n as i18n; }
8//! use ferrovue::i18n::{Args, Locale, Message, Part, Value};
9//! use ferrovue::I18n;
10//!
11//! // What the compiler writes for `en.json`: `{ "greeting": "Hello {name}!" }`.
12//! static LOCALES: &[Locale] = &[Locale::new(
13//!     "en",
14//!     &[("greeting", Message::new(&[&[Part::Text("Hello "), Part::Named("name"), Part::Text("!")]]))],
15//! )];
16//!
17//! let i18n = I18n::new(LOCALES, "en", &[]);
18//! let args = Args::new().with_named(&[("name", Value::Str("Ada"))]);
19//! assert_eq!(i18n.t("greeting", &args), "Hello Ada!");
20//! ```
21#![warn(rustdoc::missing_crate_level_docs)]
22
23use std::fmt::Write;
24
25/// One piece of a compiled message.
26#[derive(Debug)]
27#[non_exhaustive]
28pub enum Part {
29    /// Text written as it is.
30    Text(&'static str),
31    /// `{name}`: a named value.
32    Named(&'static str),
33    /// `{0}`: a value from the list.
34    List(usize),
35    /// `{'{'}`: a literal.
36    Literal(&'static str),
37    /// `@:key` or `@.modifier:key`: another message of the same locale.
38    Linked {
39        /// The message linked to.
40        key: &'static str,
41        /// `upper`, `lower` or `capitalize`, if given.
42        modifier: Option<&'static str>,
43    },
44}
45
46/// A compiled message: one case, or the cases of a plural message (`one | many`).
47#[derive(Debug)]
48#[non_exhaustive]
49pub struct Message {
50    /// The cases; a message without `|` has one.
51    pub cases: &'static [&'static [Part]],
52}
53
54impl Message {
55    /// A message of these cases, the parts of each in order. It is `const`, so a message table
56    /// can be a `static`.
57    ///
58    /// # Example
59    ///
60    /// ```
61    /// # mod ferrovue { pub use ferrovue_i18n as i18n; }
62    /// use ferrovue::i18n::{Message, Part};
63    ///
64    /// // "no apples | one apple | {count} apples"
65    /// static APPLES: Message = Message::new(&[
66    ///     &[Part::Text("no apples")],
67    ///     &[Part::Text("one apple")],
68    ///     &[Part::Named("count"), Part::Text(" apples")],
69    /// ]);
70    /// assert_eq!(APPLES.cases.len(), 3);
71    /// ```
72    #[must_use]
73    pub const fn new(cases: &'static [&'static [Part]]) -> Message {
74        Message { cases }
75    }
76}
77
78/// A locale's messages, by key (nested keys joined with dots), sorted for lookup.
79///
80/// The messages must be sorted by key, byte by byte.
81#[derive(Debug)]
82#[non_exhaustive]
83pub struct Locale {
84    /// The locale's name: its file's name, `en` for `en.json`.
85    pub name: &'static str,
86    /// `(key, message)`, sorted by key.
87    pub messages: &'static [(&'static str, Message)],
88}
89
90impl Locale {
91    /// The locale `name` with `messages`, which must be sorted by key, byte by byte. It is
92    /// `const`, so the locales can be a `static`.
93    ///
94    /// # Example
95    ///
96    /// ```
97    /// # mod ferrovue { pub use ferrovue_i18n::I18n; pub use ferrovue_i18n as i18n; }
98    /// use ferrovue::i18n::{Args, Locale, Message, Part};
99    /// use ferrovue::I18n;
100    ///
101    /// static LOCALES: &[Locale] = &[
102    ///     Locale::new("en", &[
103    ///         ("bye", Message::new(&[&[Part::Text("Bye")]])),
104    ///         ("hi", Message::new(&[&[Part::Text("Hi")]])),
105    ///     ]),
106    ///     Locale::new("nl", &[("hi", Message::new(&[&[Part::Text("Hoi")]]))]),
107    /// ];
108    ///
109    /// let i18n = I18n::new(LOCALES, "nl", &["en"]);
110    /// assert_eq!(i18n.t("hi", &Args::new()), "Hoi");
111    /// assert_eq!(i18n.t("bye", &Args::new()), "Bye");
112    /// ```
113    #[must_use]
114    pub const fn new(name: &'static str, messages: &'static [(&'static str, Message)]) -> Locale {
115        Locale { name, messages }
116    }
117
118    fn get(&'static self, key: &str) -> Option<&'static Message> {
119        self.messages
120            .binary_search_by(|(k, _)| (*k).cmp(key))
121            .ok()
122            .map(|i| &self.messages[i].1)
123    }
124}
125
126/// A value interpolated into a message, written as `toDisplayString` writes it.
127///
128/// `count` and `n` take the plural number when they are not given, or are given a falsy value.
129#[derive(Clone, Copy, Debug, PartialEq)]
130#[non_exhaustive]
131pub enum Value<'a> {
132    /// A string, as it is.
133    Str(&'a str),
134    /// An integer, as JavaScript writes a number.
135    Int(i64),
136    /// A fraction, as JavaScript writes a number.
137    Float(f64),
138    /// `true` or `false`.
139    Bool(bool),
140}
141
142impl Value<'_> {
143    fn write(&self, out: &mut String) {
144        match self {
145            Value::Str(s) => out.push_str(s),
146            Value::Int(n) => ferrovue_core::push_int(out, *n),
147            Value::Float(x) => ferrovue_core::push_number(out, *x),
148            Value::Bool(b) => out.push_str(if *b { "true" } else { "false" }),
149        }
150    }
151
152    fn truthy(&self) -> bool {
153        match self {
154            Value::Str(s) => !s.is_empty(),
155            Value::Int(n) => *n != 0,
156            Value::Float(x) => *x != 0.0 && !x.is_nan(),
157            Value::Bool(b) => *b,
158        }
159    }
160}
161
162/// What a `t()` call is given besides its key: `t(key, { named })`, `t(key, [list])`,
163/// `t(key, plural)`, or a named object and a plural together.
164///
165/// # Example
166///
167/// ```
168/// # mod ferrovue { pub use ferrovue_i18n::I18n; pub use ferrovue_i18n as i18n; }
169/// use ferrovue::i18n::{Args, Value};
170///
171/// // t("greeting", { name: "Ada" }, 3)
172/// let args = Args::new().with_named(&[("name", Value::Str("Ada"))]).with_plural(3);
173/// assert_eq!(args.plural, Some(3));
174/// ```
175#[derive(Clone, Copy, Debug, Default)]
176#[non_exhaustive]
177pub struct Args<'a> {
178    /// `{ name: value }`.
179    pub named: &'a [(&'a str, Value<'a>)],
180    /// `[value, …]`.
181    pub list: &'a [Value<'a>],
182    /// The number that chooses a plural case.
183    pub plural: Option<i64>,
184}
185
186impl<'a> Args<'a> {
187    /// `t(key)`: no named values, no list and no plural number, as [`Args::default`] is.
188    ///
189    /// # Example
190    ///
191    /// ```
192    /// # mod ferrovue { pub use ferrovue_i18n as i18n; }
193    /// let args = ferrovue::i18n::Args::new();
194    /// assert!(args.named.is_empty() && args.list.is_empty());
195    /// assert_eq!(args.plural, None);
196    /// ```
197    #[must_use]
198    pub const fn new() -> Args<'a> {
199        Args {
200            named: &[],
201            list: &[],
202            plural: None,
203        }
204    }
205
206    /// The arguments, with these named values: `t(key, { name: value })`.
207    ///
208    /// # Example
209    ///
210    /// ```
211    /// # mod ferrovue { pub use ferrovue_i18n as i18n; }
212    /// use ferrovue::i18n::{Args, Value};
213    ///
214    /// let args = Args::new().with_named(&[("name", Value::Str("Ada")), ("age", Value::Int(36))]);
215    /// assert_eq!(args.named[1], ("age", Value::Int(36)));
216    /// ```
217    #[must_use]
218    pub const fn with_named(self, named: &'a [(&'a str, Value<'a>)]) -> Args<'a> {
219        Args { named, ..self }
220    }
221
222    /// The arguments, with this list: `t(key, [value, …])`.
223    ///
224    /// # Example
225    ///
226    /// ```
227    /// # mod ferrovue { pub use ferrovue_i18n as i18n; }
228    /// use ferrovue::i18n::{Args, Value};
229    ///
230    /// let args = Args::new().with_list(&[Value::Str("Rust"), Value::Bool(true)]);
231    /// assert_eq!(args.list[0], Value::Str("Rust"));
232    /// ```
233    #[must_use]
234    pub const fn with_list(self, list: &'a [Value<'a>]) -> Args<'a> {
235        Args { list, ..self }
236    }
237
238    /// The arguments, with the number that chooses a plural case: `t(key, n)`.
239    ///
240    /// # Example
241    ///
242    /// ```
243    /// # mod ferrovue { pub use ferrovue_i18n as i18n; }
244    /// let args = ferrovue::i18n::Args::new().with_plural(2);
245    /// assert_eq!(args.plural, Some(2));
246    /// ```
247    #[must_use]
248    pub const fn with_plural(self, plural: i64) -> Args<'a> {
249        Args {
250            plural: Some(plural),
251            ..self
252        }
253    }
254}
255
256/// The messages and the locale one request renders in.
257#[derive(Clone, Debug)]
258pub struct I18n {
259    locales: &'static [Locale],
260    chain: Vec<usize>,
261    locale: String,
262}
263
264const MAX_LINK_DEPTH: usize = 32;
265
266impl I18n {
267    /// Translate in `locale`, falling back to `fallback` in order. A locale with no messages of its
268    /// own is still the current one: every key then falls back. Names that match no locale in
269    /// `locales` are skipped.
270    ///
271    /// # Example
272    ///
273    /// ```
274    /// # mod ferrovue { pub use ferrovue_i18n::I18n; pub use ferrovue_i18n as i18n; }
275    /// use ferrovue::i18n::{Args, Locale, Message, Part};
276    /// use ferrovue::I18n;
277    ///
278    /// static LOCALES: &[Locale] = &[
279    ///     Locale::new("en", &[("hi", Message::new(&[&[Part::Text("hi")]]))]),
280    ///     Locale::new("nl", &[]),
281    /// ];
282    ///
283    /// // A Belgian reader: no `nl-BE` messages, so Dutch, then English.
284    /// let i18n = I18n::new(LOCALES, "nl-BE", &["nl", "en"]);
285    /// assert_eq!(i18n.locale(), "nl-BE");
286    /// assert_eq!(i18n.t("hi", &Args::default()), "hi");
287    /// ```
288    #[must_use]
289    pub fn new(locales: &'static [Locale], locale: &str, fallback: &[&str]) -> I18n {
290        let index = |name: &str| locales.iter().position(|l| l.name == name);
291        let mut chain: Vec<usize> = Vec::new();
292        for name in std::iter::once(locale).chain(fallback.iter().copied()) {
293            if let Some(i) = index(name)
294                && !chain.contains(&i)
295            {
296                chain.push(i);
297            }
298        }
299        I18n {
300            locales,
301            chain,
302            locale: locale.to_owned(),
303        }
304    }
305
306    /// The current locale, as `useI18n().locale` reads it.
307    #[must_use]
308    pub fn locale(&self) -> &str {
309        &self.locale
310    }
311
312    /// `t(key, …)`: the message in the first locale of the chain that has it, evaluated with these
313    /// arguments, or the key itself when none does, as vue-i18n returns it.
314    ///
315    /// The result is the message's text, unescaped.
316    ///
317    /// # Example
318    ///
319    /// ```
320    /// # mod ferrovue { pub use ferrovue_i18n::I18n; pub use ferrovue_i18n as i18n; }
321    /// use ferrovue::i18n::{Args, Locale, Message, Part, Value};
322    /// use ferrovue::I18n;
323    ///
324    /// // `{ "apples": "no apples | one apple | {count} apples", "app": "ferrovue",
325    /// //    "about": "@.upper:app and {0}" }`
326    /// static LOCALES: &[Locale] = &[Locale::new("en", &[
327    ///     ("about", Message::new(&[&[
328    ///         Part::Linked { key: "app", modifier: Some("upper") },
329    ///         Part::Text(" and "),
330    ///         Part::List(0),
331    ///     ]])),
332    ///     ("app", Message::new(&[&[Part::Text("ferrovue")]])),
333    ///     ("apples", Message::new(&[
334    ///         &[Part::Text("no apples")],
335    ///         &[Part::Text("one apple")],
336    ///         &[Part::Named("count"), Part::Text(" apples")],
337    ///     ])),
338    /// ])];
339    /// let i18n = I18n::new(LOCALES, "en", &[]);
340    ///
341    /// // t("apples", 0), t("apples", 1), t("apples", 5)
342    /// let plural = |n| i18n.t("apples", &Args::new().with_plural(n));
343    /// assert_eq!([plural(0), plural(1), plural(5)], ["no apples", "one apple", "5 apples"]);
344    ///
345    /// // t("about", ["Rust"])
346    /// let args = Args::new().with_list(&[Value::Str("Rust")]);
347    /// assert_eq!(i18n.t("about", &args), "FERROVUE and Rust");
348    ///
349    /// // A key no locale has is written as itself.
350    /// assert_eq!(i18n.t("no.such.key", &Args::new()), "no.such.key");
351    /// ```
352    #[must_use]
353    pub fn t(&self, key: &str, args: &Args<'_>) -> String {
354        match self.find(0, key) {
355            Some((at, message)) => {
356                let mut out = String::new();
357                self.evaluate(message, at, args, &mut out, 0);
358                out
359            }
360            None => key.to_owned(),
361        }
362    }
363
364    /// The message under `key` in the first locale of the chain from position `from` on, with the
365    /// position of the locale that has it: every message `t()` links to, however deep, is looked
366    /// up from the locale the message `t()` found came from, as vue-i18n does.
367    fn find(&self, from: usize, key: &str) -> Option<(usize, &'static Message)> {
368        self.chain
369            .iter()
370            .enumerate()
371            .skip(from)
372            .find_map(|(at, &i)| self.locales[i].get(key).map(|m| (at, m)))
373    }
374
375    fn evaluate(
376        &self,
377        message: &Message,
378        from: usize,
379        args: &Args<'_>,
380        out: &mut String,
381        depth: usize,
382    ) {
383        let case = match message.cases {
384            [] => return,
385            [only] => *only,
386            cases => {
387                let index = plural_index(plural_choice(args), cases.len());
388                debug_assert!(
389                    index.is_some(),
390                    "a plural number of {} chooses none of the cases",
391                    plural_choice(args)
392                );
393                match index.and_then(|i| cases.get(i)) {
394                    Some(case) => *case,
395                    None => return,
396                }
397            }
398        };
399        for part in case {
400            match part {
401                Part::Text(text) | Part::Literal(text) => out.push_str(text),
402                Part::Named(name) => {
403                    if let Some(value) = named(args, name) {
404                        value.write(out);
405                    }
406                }
407                Part::List(index) => {
408                    if let Some(value) = args.list.get(*index) {
409                        value.write(out);
410                    }
411                }
412                Part::Linked { key, modifier } => {
413                    let mut linked = String::new();
414                    if depth < MAX_LINK_DEPTH
415                        && let Some((_, m)) = self.find(from, key)
416                    {
417                        self.evaluate(m, from, args, &mut linked, depth + 1);
418                    }
419                    if linked.is_empty() {
420                        linked.push_str(key);
421                    }
422                    match *modifier {
423                        Some("upper") => out.push_str(&linked.to_uppercase()),
424                        Some("lower") => out.push_str(&linked.to_lowercase()),
425                        Some("capitalize") => {
426                            let mut chars = linked.chars();
427                            if let Some(first) = chars.next() {
428                                let _ = write!(out, "{}{}", first.to_uppercase(), chars.as_str());
429                            }
430                        }
431                        _ => out.push_str(&linked),
432                    }
433                }
434            }
435        }
436    }
437}
438
439fn named<'a>(args: &Args<'a>, name: &str) -> Option<Value<'a>> {
440    let given = args.named.iter().find(|(k, _)| *k == name).map(|(_, v)| *v);
441    match (name, args.plural) {
442        ("count" | "n", Some(n)) if !given.is_some_and(|v| v.truthy()) => Some(Value::Int(n)),
443        _ => given,
444    }
445}
446
447fn plural_choice(args: &Args<'_>) -> f64 {
448    for name in ["count", "n"] {
449        match args.named.iter().find(|(k, _)| *k == name).map(|(_, v)| *v) {
450            Some(Value::Int(n)) => return n as f64,
451            Some(Value::Float(x)) if x.is_finite() => return x,
452            _ => {}
453        }
454    }
455    args.plural.map_or(-1.0, |n| n as f64)
456}
457
458fn plural_index(choice: f64, cases: usize) -> Option<usize> {
459    let choice = choice.abs();
460    if cases == 2 {
461        Some(usize::from(choice != 1.0))
462    } else {
463        // `Math.min(NaN, 2)` is `NaN`, where `f64::min` would give 2.
464        let index = choice.min(2.0);
465        (!choice.is_nan() && index.fract() == 0.0).then_some(index as usize)
466    }
467}
468
469#[cfg(test)]
470mod tests;