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#[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 #[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 #[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 #[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 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 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
216fn 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#[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 } 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 #[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}