sofie_engine/translations/
check.rs1use 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 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 #[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 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 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
225const 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}