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