Skip to main content

sofie_engine/translations/
language.rs

1use std::sync::LazyLock;
2
3use sofie_core::{DisplayZone, Shown};
4use sofie_values::{Instant, Locale, Money, Present};
5
6use super::conventions::{self, Conventions, NEUTRAL};
7use super::{CountedKeys, Message, Part, Translations, Variant, negotiate};
8use crate::message::{is_exactly_one, number_of};
9
10static PLAIN: LazyLock<Translations> =
11    LazyLock::new(|| Translations::new(&["en"]).expect("the engine's own translations parse"));
12
13#[derive(Clone, Copy, Debug)]
14pub struct Language<'a> {
15    translations: &'a Translations,
16    index: usize,
17    conventions: &'static Conventions,
18    plural_rule: PluralRule,
19    #[cfg(feature = "gates")]
20    pub(crate) gates: crate::rendering::GatesRef<'a>,
21}
22
23/// What a language's texts and formats are made of, apart from the viewer's gates.
24#[cfg(feature = "clients")]
25#[derive(Clone, Copy, Debug)]
26pub(crate) struct Texts {
27    generation: u64,
28    index: usize,
29    conventions: &'static Conventions,
30}
31
32#[cfg(feature = "clients")]
33impl Texts {
34    pub(crate) fn is_of_same_translations(self, other: Self) -> bool {
35        self.generation == other.generation
36    }
37}
38
39#[cfg(feature = "clients")]
40impl PartialEq for Texts {
41    fn eq(&self, other: &Self) -> bool {
42        self.generation == other.generation
43            && self.index == other.index
44            && std::ptr::eq(self.conventions, other.conventions)
45    }
46}
47
48impl Translations {
49    /// The principal's choice first, then `Accept-Language` by weight, then the source language.
50    #[must_use]
51    pub fn negotiate(
52        &self,
53        preferred: Option<&Locale>,
54        accept_language: Option<&str>,
55    ) -> Language<'_> {
56        let accepted = accept_language.map(negotiate::accepted).unwrap_or_default();
57        let index = preferred
58            .into_iter()
59            .chain(&accepted)
60            .find_map(|wanted| closest(&self.languages, wanted))
61            .unwrap_or(0);
62        let language = self.languages[index].language();
63        Language {
64            translations: self,
65            index,
66            conventions: conventions::for_language(language),
67            plural_rule: PluralRule::for_language(language),
68            #[cfg(feature = "gates")]
69            gates: crate::rendering::GatesRef::default(),
70        }
71    }
72}
73
74fn filled(message: &Message, arguments: &[(&str, String)]) -> String {
75    let mut text = String::new();
76    for part in message.parts() {
77        match part {
78            Part::Text(literal) => text.push_str(literal),
79            Part::Argument(name) => {
80                let value = arguments
81                    .iter()
82                    .find(|(argument, _)| *argument == name.as_str());
83                text.push_str(value.map_or("", |(_, value)| value));
84            }
85        }
86    }
87    text
88}
89
90fn closest(declared: &[Locale], wanted: &Locale) -> Option<usize> {
91    declared
92        .iter()
93        .position(|language| language == wanted)
94        .or_else(|| {
95            declared
96                .iter()
97                .position(|language| language.language() == wanted.language())
98        })
99}
100
101impl<'a> Language<'a> {
102    /// So compiling the feature in changes nothing until the app declares languages.
103    #[must_use]
104    pub fn plain() -> Language<'static> {
105        Language {
106            translations: &PLAIN,
107            index: 0,
108            conventions: &NEUTRAL,
109            plural_rule: PluralRule::OneIsOne,
110            #[cfg(feature = "gates")]
111            gates: crate::rendering::GatesRef::default(),
112        }
113    }
114
115    #[must_use]
116    pub fn locale(self) -> &'a Locale {
117        &self.translations.languages[self.index]
118    }
119
120    #[must_use]
121    pub fn field_message<'m>(self, message: &'m str) -> &'m str
122    where
123        'a: 'm,
124    {
125        self.text(message, message)
126    }
127
128    /// A value refusing in its own words (`validation.invalid`) keeps them, translated if a key.
129    #[must_use]
130    pub fn field_error(self, key: &str, arguments: &[(&str, String)], fallback: &str) -> String {
131        if key == "validation.invalid" {
132            return self.field_message(fallback).to_owned();
133        }
134        let Some(message) = self.message(key) else {
135            return fallback.to_owned();
136        };
137        if !is_source_wording(key, arguments, fallback) {
138            return self.field_message(fallback).to_owned();
139        }
140        filled(message, arguments)
141    }
142
143    pub(crate) fn text<'m>(self, key: &str, fallback: &'m str) -> &'m str
144    where
145        'a: 'm,
146    {
147        self.message(key)
148            .and_then(Message::plain)
149            .unwrap_or(fallback)
150    }
151
152    pub(crate) fn message(self, key: &str) -> Option<&'a Message> {
153        self.translations
154            .entries
155            .get_or_default(key, self.index)
156            .map(|definition| &definition.message)
157    }
158
159    /// Another language's `one` would write its singular here, so only this language's counts.
160    pub(crate) fn own_message(self, key: &str) -> Option<&'a Message> {
161        self.translations
162            .entries
163            .get(key, self.index)
164            .map(|definition| &definition.message)
165    }
166
167    /// `key.one` where the count is one, then `key.other`, then the plain key.
168    pub(crate) fn counted_message(
169        self,
170        key: &str,
171        keys: &CountedKeys,
172        count: Option<Shown<'_>>,
173    ) -> Option<&'a Message> {
174        let one = || self.own_message(&keys.one);
175        self.plural_rule
176            .is_one(count)
177            .then(one)
178            .flatten()
179            .or_else(|| self.message(&keys.other))
180            .or_else(|| self.message(key))
181    }
182
183    #[cfg(feature = "clients")]
184    pub(crate) fn plural_rule(self) -> PluralRule {
185        self.plural_rule
186    }
187
188    pub(crate) fn enumeration(self, name: &str) -> Option<&'a [Variant]> {
189        self.translations.enums.get(name).map(Vec::as_slice)
190    }
191
192    pub(crate) fn conventions(self) -> &'static Conventions {
193        self.conventions
194    }
195
196    #[cfg(feature = "clients")]
197    pub(crate) fn texts(self) -> Texts {
198        Texts {
199            generation: self.translations.generation,
200            index: self.index,
201            conventions: self.conventions,
202        }
203    }
204
205    pub(crate) fn write_money(self, out: &mut String, amount: Money, scratch: &mut String) {
206        self.conventions.write_money(out, amount, scratch);
207    }
208
209    #[must_use]
210    pub fn format(self, value: &impl Present) -> String {
211        let mut out = String::new();
212        value.write_localized(&mut out, &self);
213        out
214    }
215}
216
217/// A message other than the key's own source text was chosen by its writer
218/// (`plan.unique(…, "is already registered")`), so a translation of the key must not replace it;
219/// an empty one asks for the key's text.
220fn is_source_wording(key: &str, arguments: &[(&str, String)], fallback: &str) -> bool {
221    fallback.is_empty()
222        || Language::plain()
223            .message(key)
224            .is_none_or(|message| filled(message, arguments) == fallback)
225}
226
227pub(crate) fn write_instant(
228    language: Language<'_>,
229    out: &mut String,
230    instant: Instant,
231    scratch: &mut String,
232) {
233    language
234        .conventions
235        .write_instant(out, instant, DisplayZone::current(), scratch);
236}
237
238impl sofie_values::Conventions for Language<'_> {
239    fn write_number(&self, out: &mut String, plain: &str) {
240        self.conventions.write_number(out, plain);
241    }
242
243    fn write_money(&self, out: &mut String, amount: Money) {
244        self.conventions
245            .write_money(out, amount, &mut String::new());
246    }
247
248    fn write_date(&self, out: &mut String, iso: &str) {
249        self.conventions.write_date(out, iso, &mut String::new());
250    }
251
252    fn write_variant(&self, out: &mut String, enumeration: &str, variant: &str) {
253        let registered = self
254            .enumeration(enumeration)
255            .and_then(|variants| variants.iter().find(|known| known.name == variant));
256        match registered {
257            Some(known) => out.push_str(self.text(&known.key, &known.humanized)),
258            None => out.push_str(variant),
259        }
260    }
261}
262
263/// CLDR's plural rules for whole counts, `one` and `other` only: a language's further forms fall to
264/// `other`. A language none of these lists names may have a `one` the table cannot say (`sl`'s
265/// 101, `fil`'s 2), so it takes `other` and is not told its `one` is never shown.
266#[derive(Clone, Copy, Debug, PartialEq, Eq)]
267pub(crate) enum PluralRule {
268    OneIsOne,
269    OneIsZeroOrOne,
270    OneEndsInOneNotEleven,
271    OtherOnly,
272}
273
274const ONE_IS_ONE: [&str; 131] = [
275    "af", "an", "ar", "asa", "ast", "az", "bal", "bem", "bez", "bg", "brx", "ca", "ce", "cgg",
276    "chr", "ckb", "cs", "cy", "da", "de", "dv", "ee", "el", "en", "eo", "es", "et", "eu", "fi",
277    "fo", "fur", "fy", "ga", "gl", "gsw", "ha", "haw", "he", "hu", "ia", "io", "it", "iu", "jgo",
278    "ji", "jmc", "ka", "kaj", "kcg", "kk", "kkj", "kl", "ks", "ksb", "ksh", "ku", "ky", "lag",
279    "lb", "lg", "lij", "mas", "mgo", "ml", "mn", "mr", "mt", "nah", "naq", "nb", "nd", "ne", "nl",
280    "nn", "nnh", "no", "nr", "ny", "nyn", "om", "or", "os", "pap", "pl", "ps", "rm", "ro", "rof",
281    "rwk", "saq", "sat", "sc", "scn", "sd", "sdh", "se", "seh", "sk", "sma", "smi", "smj", "smn",
282    "sms", "sn", "so", "sq", "ss", "ssy", "st", "sv", "sw", "syr", "ta", "te", "teo", "tig", "tk",
283    "tn", "tr", "ts", "ug", "ur", "uz", "ve", "vec", "vo", "vun", "wae", "xh", "xog", "yi",
284];
285
286const ONE_IS_ZERO_OR_ONE: [&str; 27] = [
287    "ak", "am", "as", "bho", "bn", "csw", "doi", "fa", "ff", "fr", "gu", "guw", "hi", "hy", "kab",
288    "kn", "ln", "mg", "nso", "pa", "pcm", "pt", "shi", "si", "ti", "wa", "zu",
289];
290
291const ONE_ENDS_IN_ONE_NOT_ELEVEN: [&str; 11] = [
292    "be", "bs", "hr", "is", "lt", "lv", "mk", "ru", "sh", "sr", "uk",
293];
294
295#[cfg(feature = "parse")]
296const WITHOUT_ONE: [&str; 33] = [
297    "bm", "bo", "dz", "hnj", "id", "ig", "ii", "in", "ja", "jbo", "jv", "jw", "kde", "kea", "km",
298    "ko", "lkt", "lo", "ms", "my", "root", "sah", "ses", "sg", "su", "th", "to", "tpi", "vi", "wo",
299    "yo", "yue", "zh",
300];
301
302impl PluralRule {
303    pub(crate) fn for_language(language: &str) -> Self {
304        if ONE_IS_ONE.contains(&language) {
305            Self::OneIsOne
306        // CLDR's `pt` is Brazilian, where 0 is singular as in French.
307        } else if ONE_IS_ZERO_OR_ONE.contains(&language) {
308            Self::OneIsZeroOrOne
309        } else if ONE_ENDS_IN_ONE_NOT_ELEVEN.contains(&language) {
310            Self::OneEndsInOneNotEleven
311        } else {
312            Self::OtherOnly
313        }
314    }
315
316    #[cfg(feature = "parse")]
317    pub(crate) fn has_no_one(language: &str) -> bool {
318        WITHOUT_ONE.contains(&language)
319    }
320
321    /// Anything but a number is `other`.
322    #[expect(
323        clippy::float_cmp,
324        reason = "a whole count's remainders are whole, so exact"
325    )]
326    pub(crate) fn is_one(self, count: Option<Shown<'_>>) -> bool {
327        let absolute = number_of(count).map(f64::abs);
328        match self {
329            Self::OneIsOne => is_exactly_one(count),
330            Self::OneIsZeroOrOne => absolute.is_some_and(|count| count < 2.0),
331            Self::OneEndsInOneNotEleven => absolute.is_some_and(|count| {
332                count.fract() == 0.0 && count % 10.0 == 1.0 && count % 100.0 != 11.0
333            }),
334            Self::OtherOnly => false,
335        }
336    }
337
338    #[cfg(feature = "clients")]
339    pub(crate) fn name(self) -> &'static str {
340        match self {
341            Self::OneIsOne => "one-is-one",
342            Self::OneIsZeroOrOne => "one-is-zero-or-one",
343            Self::OneEndsInOneNotEleven => "one-ends-in-one-not-eleven",
344            Self::OtherOnly => "other-only",
345        }
346    }
347}
348
349#[cfg(test)]
350mod tests {
351    use super::*;
352
353    fn refusal(refused: sofie_core::Result<impl std::fmt::Debug>) -> sofie_core::FieldError {
354        match refused {
355            Err(sofie_core::Error::Invalid(mut fields)) => fields.remove(0),
356            other => panic!("not refused as invalid: {other:?}"),
357        }
358    }
359
360    #[test]
361    fn each_value_refusal_reads_in_its_own_key_in_every_shipped_language() {
362        use sofie_values::{Currency, Date, Email, Locale, Money, Slug, TimeOfDay, TimeZone, Url};
363
364        let euro = Currency::parse("EUR").unwrap();
365        let refusals = [
366            refusal(Email::parse("ada lovelace@example.com")),
367            refusal(Email::parse("ada(at)example.com")),
368            refusal(Email::parse("a\"b@example.com")),
369            refusal(Money::parse_major("1.234", euro)),
370            refusal(Money::parse_major("1,5", euro)),
371            refusal(Money::parse_major("1.5", Currency::parse("JPY").unwrap())),
372            refusal(Currency::parse("eur")),
373            refusal(Slug::parse("Not A Slug")),
374            refusal(Url::parse("ftp://example.com")),
375            refusal(Locale::parse("en-u")),
376            refusal(Date::parse("2026-02-30")),
377            refusal(TimeOfDay::parse("25:00")),
378            refusal(TimeZone::parse("Mars/Olympus")),
379        ];
380        let translations = Translations::new(&["en", "de", "tr"]).unwrap();
381        let english = translations.negotiate(None, Some("en"));
382        for refused in &refusals {
383            let arguments: Vec<(&str, String)> = refused
384                .arguments
385                .iter()
386                .map(|(name, value)| (name.as_ref(), value.clone()))
387                .collect();
388            assert_ne!(refused.key, "validation.invalid", "{refused:?}");
389            assert_eq!(
390                english
391                    .message(&refused.key)
392                    .map(|message| filled(message, &arguments))
393                    .as_ref(),
394                Some(&refused.message),
395                "{refused:?}"
396            );
397            for language in ["de", "tr"] {
398                let translated = translations.negotiate(None, Some(language)).field_error(
399                    &refused.key,
400                    &arguments,
401                    &refused.message,
402                );
403                assert_ne!(translated, refused.message, "{language}: {refused:?}");
404            }
405        }
406    }
407}