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;