Skip to main content

sofie_engine/translations/
check.rs

1use std::collections::BTreeMap;
2use std::fmt;
3
4use super::language::PluralRule;
5use super::{KeyKind, KeyUse, Translations};
6
7#[derive(Clone, Debug, PartialEq, Eq)]
8pub struct Gap {
9    pub key: String,
10    pub language: String,
11    pub kind: GapKind,
12}
13
14#[derive(Clone, Debug, PartialEq, Eq)]
15pub enum GapKind {
16    Missing,
17    Arguments {
18        expected: Vec<String>,
19        found: Vec<String>,
20    },
21    NeverPicked,
22}
23
24impl fmt::Display for Gap {
25    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
26        let Self {
27            key,
28            language,
29            kind,
30        } = self;
31        match kind {
32            GapKind::Missing => write!(f, "`{key}` has no `{language}` translation"),
33            GapKind::Arguments { expected, found } => write!(
34                f,
35                "`{key}` in `{language}` has placeholders {found:?} where the others have {expected:?}"
36            ),
37            GapKind::NeverPicked => write!(
38                f,
39                "`{key}` in `{language}` is never shown: a count there always picks `other`"
40            ),
41        }
42    }
43}
44
45#[derive(Clone, Copy, PartialEq, Eq)]
46enum Required {
47    Everywhere,
48    /// The source language may humanize the name instead.
49    BesidesSource,
50}
51
52impl Required {
53    fn of(key: &str) -> Self {
54        if is_humanized(key) {
55            Self::BesidesSource
56        } else {
57            Self::Everywhere
58        }
59    }
60}
61
62impl Translations {
63    /// Checked in every declared language; sorted by key.
64    #[must_use]
65    pub fn gaps(&self, uses: &[KeyUse]) -> Vec<Gap> {
66        let mut required: BTreeMap<&str, Required> = BTreeMap::new();
67        for key in self.entries.keys().filter(|key| !self.is_one_form(key)) {
68            required.insert(key, Required::of(key));
69        }
70        for variant in self.enums.values().flatten() {
71            required
72                .entry(&variant.key)
73                .or_insert(Required::BesidesSource);
74        }
75        for key_use in uses {
76            match key_use.kind {
77                KeyKind::Message { .. } if self.has_forms(&key_use.key) => {}
78                KeyKind::Message { .. } => {
79                    required.insert(&key_use.key, Required::of(&key_use.key));
80                }
81                KeyKind::Label => {
82                    required
83                        .entry(&key_use.key)
84                        .or_insert(Required::BesidesSource);
85                }
86                KeyKind::Enum => {}
87            }
88        }
89        let mut gaps = Vec::new();
90        for (key, requirement) in required {
91            gaps_of(self, key, requirement, &mut gaps);
92        }
93        gaps.extend(self.one_form_gaps());
94        gaps.sort_by(|left, right| left.key.cmp(&right.key));
95        gaps
96    }
97
98    /// A `one` form is wanted where the language's rule picks it, and only there.
99    fn one_form_gaps(&self) -> Vec<Gap> {
100        let mut gaps = Vec::new();
101        for key in self.entries.keys().filter(|key| self.is_one_form(key)) {
102            for (index, language) in self.languages.iter().enumerate() {
103                let tag = language.language();
104                let picks_one = PluralRule::for_language(tag) != PluralRule::OtherOnly;
105                let kind = match (picks_one, self.entries.get(key, index).is_some()) {
106                    (true, false) => GapKind::Missing,
107                    (false, true) if PluralRule::has_no_one(tag) => GapKind::NeverPicked,
108                    _ => continue,
109                };
110                gaps.push(Gap {
111                    key: key.to_owned(),
112                    language: language.as_str().to_owned(),
113                    kind,
114                });
115            }
116        }
117        gaps
118    }
119
120    fn has_forms(&self, key: &str) -> bool {
121        self.entries.contains(&format!("{key}.other"))
122    }
123
124    /// A language without a `one` form leaves it out, and it may omit the count.
125    fn is_one_form(&self, key: &str) -> bool {
126        key.strip_suffix(".one")
127            .is_some_and(|stem| self.has_forms(stem))
128    }
129}
130
131fn gaps_of(translations: &Translations, key: &str, requirement: Required, gaps: &mut Vec<Gap>) {
132    let defined: Vec<_> = (0..translations.languages.len())
133        .map(|index| translations.entries.get(key, index))
134        .collect();
135    let expected = defined
136        .iter()
137        .flatten()
138        .next()
139        .map(|first| first.message.arguments());
140    for (index, definition) in defined.iter().enumerate() {
141        let kind = match (definition, &expected) {
142            (None, _) if index == 0 && requirement == Required::BesidesSource => continue,
143            (None, _) => GapKind::Missing,
144            (Some(definition), Some(expected)) => {
145                let found = definition.message.arguments();
146                if found == *expected {
147                    continue;
148                }
149                GapKind::Arguments {
150                    expected: owned(expected),
151                    found: owned(&found),
152                }
153            }
154            (Some(_), None) => continue,
155        };
156        gaps.push(Gap {
157            key: key.to_owned(),
158            language: translations.languages[index].as_str().to_owned(),
159            kind,
160        });
161    }
162}
163
164pub(super) fn check_use(translations: &Translations, key_use: &KeyUse) -> Result<(), String> {
165    let key = key_use.key.as_str();
166    let arguments = match &key_use.kind {
167        KeyKind::Label => return Ok(()),
168        KeyKind::Enum if translations.enums.contains_key(key) => return Ok(()),
169        KeyKind::Enum => return Err(format!("`{key}` is not a registered enum")),
170        KeyKind::Message { arguments } => arguments,
171    };
172    let one = format!("{key}.one");
173    let other = format!("{key}.other");
174    let is_defined = |key: &str| translations.entries.get(key, 0).is_some();
175    if !is_defined(key) && !is_defined(&other) && !is_humanized(key) {
176        return Err(format!(
177            "translation key `{key}` has no text in `{}`, the source language",
178            translations.languages[0].as_str()
179        ));
180    }
181    if !is_defined(key) && is_defined(&other) && !arguments.iter().any(|name| name == "count") {
182        return Err(format!(
183            "translation key `{key}` has only plural forms, so the call needs a `count`"
184        ));
185    }
186    for index in 0..translations.languages.len() {
187        check_placeholders(translations, key, index, arguments, |found| {
188            found == arguments
189        })?;
190        for form in [&one, &other] {
191            check_placeholders(translations, form, index, arguments, |found| {
192                found
193                    .iter()
194                    .all(|name| arguments.iter().any(|given| given == name))
195            })?;
196        }
197    }
198    Ok(())
199}
200
201fn check_placeholders(
202    translations: &Translations,
203    key: &str,
204    index: usize,
205    arguments: &[String],
206    fits: impl Fn(&[&str]) -> bool,
207) -> Result<(), String> {
208    let Some(definition) = translations.entries.get(key, index) else {
209        return Ok(());
210    };
211    let placeholders = definition.message.arguments();
212    if fits(&placeholders) {
213        return Ok(());
214    }
215    Err(format!(
216        "`{key}` in `{}` has placeholders {placeholders:?}, but this call gives {arguments:?}",
217        translations.languages[index].as_str()
218    ))
219}
220
221fn owned(names: &[&str]) -> Vec<String> {
222    names.iter().map(|name| (*name).to_owned()).collect()
223}
224
225/// The source language may leave these out: the humanized name is the text there.
226const HUMANIZED_PREFIXES: [&str; 2] = ["label.", "enum."];
227
228fn is_humanized(key: &str) -> bool {
229    HUMANIZED_PREFIXES
230        .iter()
231        .any(|prefix| key.starts_with(prefix))
232}