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