Skip to main content

qframe/i18n/
check.rs

1//! The checks a multi-language application runs on its own locale files, in a test.
2//!
3//! Every file carrying every key with the right placeholders and the plural forms its language
4//! needs can still be nine copies of English, and a check that only counts keys cannot see it.
5//! [`locales`] runs the three checks on the shape of a set of files and [`own_words`] the one on
6//! whether a language says anything of its own; each of [`same_keys`], [`same_placeholders`] and
7//! [`plural_forms`] can be used on its own.
8//!
9//! ```rust
10//! let en = "[meta]\nname = \"English\"\ncode = \"en\"\n\n[app]\nsave = \"Save\"\n";
11//! let tr = "[meta]\nname = \"Türkçe\"\ncode = \"tr\"\n\n[app]\nsave = \"Kaydet\"\n";
12//! let files = [("en.toml", en), ("tr.toml", tr)];
13//! let problems = qframe::i18n::check::locales(&files);
14//! assert!(problems.is_empty(), "{problems:?}");
15//! ```
16//!
17//! The files are read with the parser the runtime reads them with and the plural rules it chooses
18//! their forms with, so a file these calls call complete is one the runtime can draw. The first
19//! file is the reference: every other file is compared with it and nothing with anything else. A
20//! file the parser cannot read whole is a [`Problem`] of its own and takes no part in the
21//! comparison, and a reference that cannot be read leaves the files after it uncompared.
22
23use std::fmt;
24use std::ops::RangeInclusive;
25
26use super::locale::{self, Locale, Message, Piece, Place, Template};
27use super::plural::PluralCategory;
28use crate::diagnostics::{Diagnostic, Location};
29
30/// One problem found in a locale file.
31///
32/// Its [`Display`](fmt::Display) is what an editor opens: the file, the line, the column and what
33/// is wrong. The message is for whoever reads the failing test, so it is written in English and
34/// never comes from a language file.
35#[derive(Debug, Clone, PartialEq, Eq)]
36#[non_exhaustive]
37pub struct Problem {
38    /// The file the problem is in, under the name it was given.
39    pub file: String,
40    /// The line, counting from 1. A problem with a whole file rather than a place in it is
41    /// reported at its first line.
42    pub line: usize,
43    /// The column in characters, counting from 1.
44    pub column: usize,
45    /// The message the problem belongs to, when it belongs to one.
46    pub key: Option<String>,
47    /// What is wrong.
48    pub message: String,
49}
50
51impl fmt::Display for Problem {
52    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
53        write!(f, "{}:{}:{}: {}", self.file, self.line, self.column, self.message)
54    }
55}
56
57/// Every key of the reference in every other file, no key of its own anywhere, the same
58/// placeholders wherever both files have a message, and the plural forms every language's own
59/// rule chooses from. [`own_words`] is the one left out: whether a language has been translated
60/// is a decision the application makes, not a mistake it made.
61#[must_use]
62pub fn locales(files: &[(&str, &str)]) -> Vec<Problem> {
63    let Loaded { files, mut problems } = load(files);
64    problems.extend(key_problems(&files));
65    problems.extend(placeholder_problems(&files));
66    problems.extend(form_problems(&files));
67    problems
68}
69
70/// Every key of the reference file in every other file, and no key one of them has on its own.
71///
72/// A key the reference has and another file lacks is a problem in the reference file, at the
73/// line that key is written on, since that is where the key to translate is; a key another file
74/// has and the reference does not is a problem in that file, at its own line, since nothing draws
75/// it. The exception is a numbered key of a family a language may add on its own, such as
76/// `quvyta.date.month-in-date-1` to `month-in-date-12`, which the month names of only some
77/// languages need; once the reference itself has such a key, every file must have it too.
78#[must_use]
79pub fn same_keys(files: &[(&str, &str)]) -> Vec<Problem> {
80    let Loaded { files, mut problems } = load(files);
81    problems.extend(key_problems(&files));
82    problems
83}
84
85/// The same `{name}` placeholders wherever both files have the message.
86///
87/// A placeholder is compared by name, so `{count}` where the reference writes `{n}` is a problem:
88/// the argument the call passes is named `n`, and the message would show the placeholder itself.
89/// Using one placeholder twice counts, since a message that writes `{name}` twice and a reference
90/// that writes it once are not the same message; the order they are written in does not, since a
91/// language puts its words where its own grammar wants them. A form only one of the two files
92/// has is left to [`plural_forms`], and where one of them writes a plain string and the other a
93/// plural table, the string is compared with the table's `other` form, the one a count falls back
94/// to.
95#[must_use]
96pub fn same_placeholders(files: &[(&str, &str)]) -> Vec<Problem> {
97    let Loaded { files, mut problems } = load(files);
98    problems.extend(placeholder_problems(&files));
99    problems
100}
101
102/// The plural forms each language's own plural rule can choose from.
103///
104/// A form a language's rule chooses and one of its messages does not have is a problem, named
105/// with the first count that chooses it — Russian `few` is what 2 is, Japanese never chooses
106/// anything but `other`. A form the rule never chooses is left alone: it is never drawn, so a
107/// language keeping one costs nothing.
108///
109/// A plain string where the reference has a plural table is the language's own choice and is not
110/// a problem: Turkish counts 1 and 5 with the same words and writes `"{n} öğeyi kes"` where
111/// English has two forms, and the runtime draws that string for every count. Nothing in the text
112/// says whether a language would have said it another way, so nothing here guesses.
113///
114/// The reference is read with the others, since a form its own rule chooses and it does not have
115/// is as wrong as one missing from another language.
116#[must_use]
117pub fn plural_forms(files: &[(&str, &str)]) -> Vec<Problem> {
118    let Loaded { files, mut problems } = load(files);
119    problems.extend(form_problems(&files));
120    problems
121}
122
123/// Whether every file says its own words: at least `at_least` of the values of each file after
124/// the reference must differ from the reference's. A file whose values are all English passes
125/// every other check here, while a word two languages happen to share is normal, which is what a
126/// share rather than a single value is for. `0.8` asks for a real translation.
127///
128/// Left out of both the number of values and the share:
129///
130/// - a plural table, whose forms are counted one by one by [`plural_forms`] and whose several
131///   forms are a language's wording as a whole;
132/// - a value with no letters in it, which says nothing in any language: a pure placeholder like
133///   `{n}`, a number like `7`, the shape of a clock `{hour}:{minute}`;
134/// - a key name: a value made only of ASCII letters, digits and the punctuation of a shortcut,
135///   with the `+` that joins the keys of one, such as `ctrl+s`. A single word like `on` is a word
136///   rather than a key name, so it is counted.
137///
138/// A file with nothing left to say in words is not reported, and a key it does not have at all is
139/// left to [`same_keys`]. A file that says too little is one problem, at its first line, naming
140/// how many of its values are the same.
141#[must_use]
142pub fn own_words(files: &[(&str, &str)], at_least: f32) -> Vec<Problem> {
143    let Loaded { files, mut problems } = load(files);
144    let Some((reference_file, reference)) = files.first() else {
145        return problems;
146    };
147    for (file, locale) in &files[1..] {
148        let (same, total) = share(reference, locale);
149        if total == 0 {
150            continue;
151        }
152        let differing = (total - same) as f32 / total as f32;
153        if differing < at_least {
154            problems.push(Problem {
155                file: (*file).to_owned(),
156                line: 1,
157                column: 1,
158                key: None,
159                // Written so the count reads the same however many there are: a test failure is
160                // read by a person in a hurry.
161                message: format!(
162                    "it says the same words as `{reference_file}` in {same} of its {total} values, so it reads as `{}`; at least {at_least} of them must be its own words",
163                    reference.code
164                ),
165            });
166        }
167    }
168    problems
169}
170
171/// The files of one call, read once: every file the parser could read whole, and what it said
172/// about the ones it could not.
173struct Loaded<'a> {
174    /// `(name, locale)` in the order given, the reference first.
175    files: Vec<(&'a str, Locale)>,
176    /// What is wrong with a file the parser did not return whole: a broken line, a missing
177    /// `[meta]`, a value that is not a string. The runtime reports the same things as diagnostics,
178    /// and a check that stayed quiet about them would pass a file it cannot draw.
179    problems: Vec<Problem>,
180}
181
182/// Reads every file of a call, keeping what the parser said about each of them.
183fn load<'a>(files: &[(&'a str, &'a str)]) -> Loaded<'a> {
184    let mut loaded = Loaded { files: Vec::new(), problems: Vec::new() };
185    for (index, (file, text)) in files.iter().enumerate() {
186        let mut report = Vec::new();
187        if let Some(locale) = locale::parse(file, text, &mut report) {
188            loaded.files.push((file, locale));
189        }
190        loaded.problems.extend(report.into_iter().map(|problem| Problem::reported(file, &problem)));
191        // The first file is the reference; without one there is nothing to compare the rest with,
192        // so they keep only what the parser said about them.
193        if index == 0 && loaded.files.is_empty() {
194            break;
195        }
196    }
197    loaded
198}
199
200impl Problem {
201    /// A problem the parser gave about a file, at the place it named or at the top of the file.
202    fn reported(file: &str, diagnostic: &Diagnostic) -> Self {
203        let (line, column) = diagnostic.location.as_ref().map_or((1, 1), |at| (at.line, at.column));
204        Self { file: file.to_owned(), line, column, key: None, message: diagnostic.message.clone() }
205    }
206
207    /// A problem about a message, at the place that message is written.
208    fn at(place: &Location, key: &str, message: String) -> Self {
209        Self { file: place.file.clone(), line: place.line, column: place.column, key: Some(key.to_owned()), message }
210    }
211}
212
213/// Where a message of `locale` is written: a message only enters a file together with the place it
214/// was written at, so every message of a file that could be read has one.
215fn place_of<'l>(locale: &'l Locale, key: &str) -> &'l Place {
216    locale.places.get(key).expect("every message of a file keeps the line it was written on")
217}
218
219/// Every key the reference has and another file lacks, and every key another file has alone.
220fn key_problems(files: &[(&str, Locale)]) -> Vec<Problem> {
221    let Some((reference_file, reference)) = files.first() else {
222        return Vec::new();
223    };
224    let mut problems = Vec::new();
225    for (file, locale) in &files[1..] {
226        for key in reference.messages.keys() {
227            if !locale.messages.contains_key(key) {
228                let message = format!("`{key}` is missing from `{file}`");
229                problems.push(Problem::at(&place_of(reference, key).key, key, message));
230            }
231        }
232        for key in locale.messages.keys() {
233            if !reference.messages.contains_key(key) && !optional_key(key) {
234                let message = format!("`{key}` is in `{file}` but not in `{reference_file}`, so nothing draws it");
235                problems.push(Problem::at(&place_of(locale, key).key, key, message));
236            }
237        }
238    }
239    problems
240}
241
242/// Key families a language may add on its own, each written with the `-` that its numbers follow:
243/// the month names a language writes its own way inside a date, which Russian, Spanish, French
244/// and Brazilian Portuguese among the nine do. A key of such a family is a language's own answer,
245/// not a key the reference forgot.
246const OPTIONAL_FAMILIES: [&str; 1] = ["quvyta.date.month-in-date-"];
247
248/// Whether `key` is a numbered key of a family a language may add on its own.
249fn optional_key(key: &str) -> bool {
250    OPTIONAL_FAMILIES.iter().any(|family| {
251        key.strip_prefix(family).is_some_and(|number| !number.is_empty() && number.bytes().all(|b| b.is_ascii_digit()))
252    })
253}
254
255/// Every message of one file that the reference has too, compared form by form.
256fn placeholder_problems(files: &[(&str, Locale)]) -> Vec<Problem> {
257    let Some((_, reference)) = files.first() else {
258        return Vec::new();
259    };
260    let mut problems = Vec::new();
261    for (_, locale) in &files[1..] {
262        for (key, wanted) in &reference.messages {
263            // A key the file does not have is named by `same_keys`.
264            let Some(given) = locale.messages.get(key) else { continue };
265            let place = place_of(locale, key);
266            for (category, here, there) in comparable(given, wanted) {
267                let (here, there) = (placeholders(here), placeholders(there));
268                if here == there {
269                    continue;
270                }
271                let form = match given {
272                    Message::Plain(_) => String::new(),
273                    Message::Plural(_) => format!("'s `{}` form", category.name()),
274                };
275                let message = format!("`{key}`{form} takes the placeholders {here} where the reference takes {there}");
276                problems.push(Problem::at(place.form(category), key, message));
277            }
278        }
279    }
280    problems
281}
282
283/// The `{name}` placeholders a message writes, each one once per use, in name order so a language
284/// that puts its words in another order is not reported: `{n} {unit}`, or `none`.
285fn placeholders(template: &Template) -> String {
286    let mut names: Vec<&str> = template
287        .0
288        .iter()
289        .filter_map(|piece| match piece {
290            Piece::Text(_) => None,
291            Piece::Arg(name) => Some(name.as_str()),
292        })
293        .collect();
294    if names.is_empty() {
295        return "none".to_owned();
296    }
297    names.sort_unstable();
298    names.iter().map(|name| format!("{{{name}}}")).collect::<Vec<_>>().join(" ")
299}
300
301/// The forms of two messages that can be compared with each other, each with the category both
302/// sides know it by: a plain string has no category of its own, so it is matched with the `other`
303/// form of a table on the other side, the form a count falls back to.
304fn comparable<'m>(given: &'m Message, wanted: &'m Message) -> Vec<(PluralCategory, &'m Template, &'m Template)> {
305    let (given, wanted) = match (given, wanted) {
306        (Message::Plain(template), Message::Plural(_)) => (vec![(PluralCategory::Other, template)], written(wanted)),
307        (table @ Message::Plural(_), Message::Plain(template)) => {
308            (written(table), vec![(PluralCategory::Other, template)])
309        }
310        (given, wanted) => (written(given), written(wanted)),
311    };
312    wanted
313        .iter()
314        .filter_map(|(category, wanted)| {
315            let (_, given) = given.iter().copied().find(|(known, _)| known == category)?;
316            Some((*category, given, *wanted))
317        })
318        .collect()
319}
320
321/// The forms of a message, a plain string being the `other` form of a message with no count in it.
322fn written(message: &Message) -> Vec<(PluralCategory, &Template)> {
323    match message {
324        Message::Plain(template) => vec![(PluralCategory::Other, template)],
325        Message::Plural(forms) => forms.iter().map(|(category, template)| (*category, template)).collect(),
326    }
327}
328
329/// Every form a language's rule chooses from and a message of its own does not have.
330fn form_problems(files: &[(&str, Locale)]) -> Vec<Problem> {
331    let mut problems = Vec::new();
332    for (_, locale) in files {
333        let needed = needed_forms(&locale.code);
334        for (key, message) in &locale.messages {
335            let Message::Plural(forms) = message else { continue };
336            for (category, count) in &needed {
337                if !forms.contains_key(category) {
338                    let message =
339                        format!("`{key}` has no `{}` form, which {} uses for {count}", category.name(), locale.code);
340                    problems.push(Problem::at(&place_of(locale, key).key, key, message));
341                }
342            }
343        }
344    }
345    problems
346}
347
348/// The counts a plural rule is read over, the range the built-in language files are checked with:
349/// every form a rule can choose for a count a screen shows is in it.
350const COUNTS: RangeInclusive<i64> = 0..=200;
351
352/// The forms a language's own rule can choose from, each with the first count that chooses it.
353fn needed_forms(language: &str) -> Vec<(PluralCategory, i64)> {
354    let mut needed = Vec::new();
355    for category in PluralCategory::ALL {
356        if let Some(count) = COUNTS.clone().find(|n| PluralCategory::of(language, *n) == category) {
357            needed.push((category, count));
358        }
359    }
360    needed
361}
362
363/// How many of a file's values say the same words as the reference's, and how many of them could:
364/// the values that are the same in every language by nature are left out of both.
365fn share(reference: &Locale, locale: &Locale) -> (usize, usize) {
366    let (mut same, mut total) = (0, 0);
367    for (key, message) in &locale.messages {
368        // A key only the reference has, or a plural table on either side, is left to the other
369        // checks rather than read as a value the language failed to say its own way.
370        let (Message::Plain(given), Some(Message::Plain(wanted))) = (message, reference.messages.get(key)) else {
371            continue;
372        };
373        let given = given.words();
374        if by_nature(&given) {
375            continue;
376        }
377        total += 1;
378        if given == wanted.words() {
379            same += 1;
380        }
381    }
382    (same, total)
383}
384
385/// Whether a value says the same thing in every language by nature rather than untranslated: it
386/// has no letters in it, as a pure placeholder, a number and a shape like a clock do not, or it
387/// is a key name, which the `+` of a shortcut gives away.
388fn by_nature(words: &str) -> bool {
389    !words.chars().any(char::is_alphabetic) || shortcut(words)
390}
391
392/// Whether `words` is a key name: it has the `+` that joins the keys of a shortcut, and nothing
393/// but ASCII letters, digits and the punctuation one is written with.
394fn shortcut(words: &str) -> bool {
395    words.contains('+') && words.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '_' | '.' | '/'))
396}
397
398#[cfg(test)]
399mod tests {
400    use super::*;
401
402    const EN: &str = r#"[meta]
403name = "English"
404code = "en"
405
406[app]
407save = "Save"
408count = { one = "{n} file", other = "{n} files" }
409key = "ctrl+s"
410"#;
411
412    const TR: &str = r#"[meta]
413name = "Türkçe"
414code = "tr"
415fallback = "en"
416
417[app]
418save = "Kaydet"
419count = { one = "{n} dosya", other = "{n} dosya" }
420key = "ctrl+s"
421"#;
422
423    /// Russian, whose plural rule chooses three forms where English chooses two.
424    const RU: &str = r#"[meta]
425name = "Русский"
426code = "ru"
427
428[app]
429save = "Сохранить"
430count = { one = "{n} файл", few = "{n} файла", many = "{n} файлов", other = "{n} файлов" }
431key = "ctrl+s"
432"#;
433
434    /// The problems as `(file, line, key, message)`, which is what a test reads.
435    fn shown(problems: &[Problem]) -> Vec<(String, usize, Option<String>, String)> {
436        problems
437            .iter()
438            .map(|problem| (problem.file.clone(), problem.line, problem.key.clone(), problem.message.clone()))
439            .collect()
440    }
441
442    #[test]
443    fn a_complete_pair_of_files_has_no_problems() {
444        let files = [("en.toml", EN), ("tr.toml", TR)];
445        assert_eq!(shown(&locales(&files)), Vec::new());
446        assert_eq!(shown(&same_keys(&files)), Vec::new());
447        assert_eq!(shown(&same_placeholders(&files)), Vec::new());
448        assert_eq!(shown(&plural_forms(&files)), Vec::new());
449        assert_eq!(shown(&own_words(&files, 0.8)), Vec::new(), "Turkish wrote a word of its own");
450        // A language whose rule chooses three forms keeps all three.
451        let russian = [("en.toml", EN), ("ru.toml", RU)];
452        assert_eq!(shown(&plural_forms(&russian)), Vec::new());
453        assert_eq!(shown(&locales(&russian)), Vec::new());
454    }
455
456    #[test]
457    fn a_missing_key_is_named_at_the_line_of_the_reference() {
458        let tr = TR.replace("save = \"Kaydet\"\n", "");
459        let files = [("en.toml", EN), ("tr.toml", tr.as_str())];
460        let problems = same_keys(&files);
461        assert_eq!(
462            shown(&problems),
463            vec![(
464                "en.toml".to_owned(),
465                6,
466                Some("app.save".to_owned()),
467                "`app.save` is missing from `tr.toml`".to_owned()
468            )]
469        );
470        assert_eq!(problems[0].to_string(), "en.toml:6:1: `app.save` is missing from `tr.toml`");
471        assert_eq!(locales(&files).len(), 1, "and once only through `locales`");
472    }
473
474    #[test]
475    fn a_key_the_reference_does_not_have_is_named_at_its_own_line() {
476        let tr = TR.replace("key = \"ctrl+s\"\n", "key = \"ctrl+s\"\nquit = \"Çık\"\n");
477        let files = [("en.toml", EN), ("tr.toml", tr.as_str())];
478        assert_eq!(
479            shown(&same_keys(&files)),
480            vec![(
481                "tr.toml".to_owned(),
482                10,
483                Some("app.quit".to_owned()),
484                "`app.quit` is in `tr.toml` but not in `en.toml`, so nothing draws it".to_owned()
485            )]
486        );
487    }
488
489    #[test]
490    fn a_language_may_add_the_month_names_its_own_rule_needs() {
491        // Russian declines a month name inside a date, so its file carries a numbered key family
492        // the other languages do not have: its own answer, not a key the reference forgot.
493        let ru = RU.replace("key = \"ctrl+s\"\n", "key = \"ctrl+s\"\n\n[quvyta.date]\nmonth-in-date-1 = \"января\"\n");
494        let files = [("en.toml", EN), ("ru.toml", ru.as_str())];
495        assert_eq!(shown(&same_keys(&files)), Vec::new());
496    }
497
498    #[test]
499    fn a_placeholder_written_under_another_name_is_named() {
500        let tr = TR.replace("{n} dosya", "{count} dosya");
501        let files = [("en.toml", EN), ("tr.toml", tr.as_str())];
502        assert_eq!(
503            shown(&same_placeholders(&files)),
504            vec![
505                (
506                    "tr.toml".to_owned(),
507                    8,
508                    Some("app.count".to_owned()),
509                    "`app.count`'s `one` form takes the placeholders {count} where the reference takes {n}".to_owned()
510                ),
511                (
512                    "tr.toml".to_owned(),
513                    8,
514                    Some("app.count".to_owned()),
515                    "`app.count`'s `other` form takes the placeholders {count} where the reference takes {n}"
516                        .to_owned()
517                ),
518            ]
519        );
520    }
521
522    #[test]
523    fn a_placeholder_left_out_of_one_form_only_is_named_there() {
524        let en = EN.replace("other = \"{n} files\"", "other = \"{n} files in {dir}\"");
525        let files = [("en.toml", en.as_str()), ("tr.toml", TR)];
526        let problems = same_placeholders(&files);
527        assert_eq!(problems.len(), 1, "only the form that differs: {problems:?}");
528        assert!(problems[0].message.contains("{dir}"), "{}", problems[0]);
529        assert!(!problems[0].message.contains("`one` form"), "the form beside it is untouched: {}", problems[0]);
530    }
531
532    #[test]
533    fn a_language_that_counts_in_three_ways_needs_three_forms() {
534        let ru = RU.replace("few = \"{n} файла\", ", "");
535        let files = [("en.toml", EN), ("ru.toml", ru.as_str())];
536        assert_eq!(
537            shown(&plural_forms(&files)),
538            vec![(
539                "ru.toml".to_owned(),
540                7,
541                Some("app.count".to_owned()),
542                "`app.count` has no `few` form, which ru uses for 2".to_owned()
543            )]
544        );
545    }
546
547    #[test]
548    fn a_language_may_write_a_plain_string_where_the_reference_has_a_table() {
549        // Turkish counts 1 and 5 with the same words, so its file writes one string where English
550        // has two forms, and the runtime draws it for every count.
551        let tr = TR.replace("count = { one = \"{n} dosya\", other = \"{n} dosya\" }", "count = \"{n} dosya\"");
552        let files = [("en.toml", EN), ("tr.toml", tr.as_str())];
553        assert_eq!(shown(&plural_forms(&files)), Vec::new());
554        assert_eq!(shown(&locales(&files)), Vec::new(), "the placeholders still have to match");
555    }
556
557    #[test]
558    fn a_file_copied_from_the_reference_is_complete_and_says_nothing_of_its_own() {
559        let turkish = EN.replace("name = \"English\"", "name = \"Türkçe\"").replace("code = \"en\"", "code = \"tr\"");
560        let files = [("en.toml", EN), ("tr.toml", turkish.as_str())];
561        assert_eq!(shown(&locales(&files)), Vec::new(), "every key, placeholder and form is there");
562        assert_eq!(
563            shown(&own_words(&files, 0.8)),
564            vec![(
565                "tr.toml".to_owned(),
566                1,
567                None,
568                "it says the same words as `en.toml` in 1 of its 1 values, so it reads as `en`; at least 0.8 of them must be its own words"
569                    .to_owned()
570            )]
571        );
572    }
573
574    #[test]
575    fn the_values_that_are_the_same_in_every_language_are_left_out_of_the_share() {
576        // A file that translated everything there is to translate and nothing else: a shortcut, a
577        // size made of placeholders and the first day of a week are a number.
578        let en = EN.replace("key = \"ctrl+s\"", "key = \"ctrl+s\"\nsize = \"{n} {unit}\"\nfirst-weekday = \"1\"");
579        let tr = TR.replace("key = \"ctrl+s\"", "key = \"ctrl+s\"\nsize = \"{n} {unit}\"\nfirst-weekday = \"1\"");
580        let files = [("en.toml", en.as_str()), ("tr.toml", tr.as_str())];
581        assert_eq!(shown(&own_words(&files, 0.8)), Vec::new(), "nothing was left to translate");
582        // A file with nothing but those values has no words of its own to show, and is not
583        // reported for that; the keys it lacks are named by `same_keys`.
584        let bare = "[meta]\nname = \"Türkçe\"\ncode = \"tr\"\n\n[app]\nkey = \"ctrl+s\"\nsize = \"{n} {unit}\"\n";
585        let alone = [("en.toml", en.as_str()), ("tr.toml", bare)];
586        assert_eq!(shown(&own_words(&alone, 0.8)), Vec::new());
587        assert_eq!(shown(&same_keys(&alone)).len(), 3, "app.save, app.count and app.first-weekday");
588    }
589
590    #[test]
591    fn a_broken_file_is_a_problem_and_not_a_panic() {
592        let files = [("en.toml", EN), ("tr.toml", "[meta]\nname = \"Türkçe\"\ncode = \n")];
593        for problems in [
594            locales(&files),
595            same_keys(&files),
596            same_placeholders(&files),
597            plural_forms(&files),
598            own_words(&files, 0.8),
599        ] {
600            assert_eq!(problems.len(), 1, "{problems:?}");
601            assert_eq!(problems[0].file, "tr.toml");
602            assert_eq!(problems[0].line, 3, "the line the parser stopped at: {}", problems[0]);
603        }
604        // A file without `[meta]` is named too, and the files beside it are still compared.
605        let nameless = [("en.toml", EN), ("tr.toml", "[app]\nsave = \"Kaydet\"\n")];
606        let problems = same_keys(&nameless);
607        assert_eq!(problems.len(), 1, "{problems:?}");
608        assert!(problems[0].message.contains("[meta]"), "{}", problems[0]);
609        // A reference that cannot be read leaves nothing to compare with, and says so once.
610        let unreadable = [("en.toml", "[meta]\nname = \n"), ("tr.toml", TR)];
611        let problems = same_keys(&unreadable);
612        assert_eq!(problems.len(), 1, "{problems:?}");
613        assert_eq!(problems[0].file, "en.toml");
614    }
615
616    #[test]
617    fn the_frameworks_own_languages_pass_every_check() {
618        let named: Vec<(String, &str)> =
619            crate::assets::LOCALES.iter().map(|(code, text)| (format!("{code}.toml"), *text)).collect();
620        // The first file is the reference, and the built-in locales are not in that order.
621        let english = named.iter().position(|(name, _)| name == "en.toml").expect("English is one of them");
622        let mut ordered = named.clone();
623        ordered.swap(english, 0);
624        let files: Vec<(&str, &str)> = ordered.iter().map(|(name, text)| (name.as_str(), *text)).collect();
625        let problems = locales(&files);
626        assert!(problems.is_empty(), "{}", problems.iter().map(ToString::to_string).collect::<Vec<_>>().join("\n"));
627        let own = own_words(&files, 0.8);
628        assert!(own.is_empty(), "{}", own.iter().map(ToString::to_string).collect::<Vec<_>>().join("\n"));
629    }
630}