use std::fmt;
use std::ops::RangeInclusive;
use super::locale::{self, Locale, Message, Piece, Place, Template};
use super::plural::PluralCategory;
use crate::diagnostics::{Diagnostic, Location};
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct Problem {
pub file: String,
pub line: usize,
pub column: usize,
pub key: Option<String>,
pub message: String,
}
impl fmt::Display for Problem {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}:{}:{}: {}", self.file, self.line, self.column, self.message)
}
}
#[must_use]
pub fn locales(files: &[(&str, &str)]) -> Vec<Problem> {
let Loaded { files, mut problems } = load(files);
problems.extend(key_problems(&files));
problems.extend(placeholder_problems(&files));
problems.extend(form_problems(&files));
problems
}
#[must_use]
pub fn same_keys(files: &[(&str, &str)]) -> Vec<Problem> {
let Loaded { files, mut problems } = load(files);
problems.extend(key_problems(&files));
problems
}
#[must_use]
pub fn same_placeholders(files: &[(&str, &str)]) -> Vec<Problem> {
let Loaded { files, mut problems } = load(files);
problems.extend(placeholder_problems(&files));
problems
}
#[must_use]
pub fn plural_forms(files: &[(&str, &str)]) -> Vec<Problem> {
let Loaded { files, mut problems } = load(files);
problems.extend(form_problems(&files));
problems
}
#[must_use]
pub fn own_words(files: &[(&str, &str)], at_least: f32) -> Vec<Problem> {
let Loaded { files, mut problems } = load(files);
let Some((reference_file, reference)) = files.first() else {
return problems;
};
for (file, locale) in &files[1..] {
let (same, total) = share(reference, locale);
if total == 0 {
continue;
}
let differing = (total - same) as f32 / total as f32;
if differing < at_least {
problems.push(Problem {
file: (*file).to_owned(),
line: 1,
column: 1,
key: None,
message: format!(
"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",
reference.code
),
});
}
}
problems
}
struct Loaded<'a> {
files: Vec<(&'a str, Locale)>,
problems: Vec<Problem>,
}
fn load<'a>(files: &[(&'a str, &'a str)]) -> Loaded<'a> {
let mut loaded = Loaded { files: Vec::new(), problems: Vec::new() };
for (index, (file, text)) in files.iter().enumerate() {
let mut report = Vec::new();
if let Some(locale) = locale::parse(file, text, &mut report) {
loaded.files.push((file, locale));
}
loaded.problems.extend(report.into_iter().map(|problem| Problem::reported(file, &problem)));
if index == 0 && loaded.files.is_empty() {
break;
}
}
loaded
}
impl Problem {
fn reported(file: &str, diagnostic: &Diagnostic) -> Self {
let (line, column) = diagnostic.location.as_ref().map_or((1, 1), |at| (at.line, at.column));
Self { file: file.to_owned(), line, column, key: None, message: diagnostic.message.clone() }
}
fn at(place: &Location, key: &str, message: String) -> Self {
Self { file: place.file.clone(), line: place.line, column: place.column, key: Some(key.to_owned()), message }
}
}
fn place_of<'l>(locale: &'l Locale, key: &str) -> &'l Place {
locale.places.get(key).expect("every message of a file keeps the line it was written on")
}
fn key_problems(files: &[(&str, Locale)]) -> Vec<Problem> {
let Some((reference_file, reference)) = files.first() else {
return Vec::new();
};
let mut problems = Vec::new();
for (file, locale) in &files[1..] {
for key in reference.messages.keys() {
if !locale.messages.contains_key(key) {
let message = format!("`{key}` is missing from `{file}`");
problems.push(Problem::at(&place_of(reference, key).key, key, message));
}
}
for key in locale.messages.keys() {
if !reference.messages.contains_key(key) && !optional_key(key) {
let message = format!("`{key}` is in `{file}` but not in `{reference_file}`, so nothing draws it");
problems.push(Problem::at(&place_of(locale, key).key, key, message));
}
}
}
problems
}
const OPTIONAL_FAMILIES: [&str; 1] = ["quvyta.date.month-in-date-"];
fn optional_key(key: &str) -> bool {
OPTIONAL_FAMILIES.iter().any(|family| {
key.strip_prefix(family).is_some_and(|number| !number.is_empty() && number.bytes().all(|b| b.is_ascii_digit()))
})
}
fn placeholder_problems(files: &[(&str, Locale)]) -> Vec<Problem> {
let Some((_, reference)) = files.first() else {
return Vec::new();
};
let mut problems = Vec::new();
for (_, locale) in &files[1..] {
for (key, wanted) in &reference.messages {
let Some(given) = locale.messages.get(key) else { continue };
let place = place_of(locale, key);
for (category, here, there) in comparable(given, wanted) {
let (here, there) = (placeholders(here), placeholders(there));
if here == there {
continue;
}
let form = match given {
Message::Plain(_) => String::new(),
Message::Plural(_) => format!("'s `{}` form", category.name()),
};
let message = format!("`{key}`{form} takes the placeholders {here} where the reference takes {there}");
problems.push(Problem::at(place.form(category), key, message));
}
}
}
problems
}
fn placeholders(template: &Template) -> String {
let mut names: Vec<&str> = template
.0
.iter()
.filter_map(|piece| match piece {
Piece::Text(_) => None,
Piece::Arg(name) => Some(name.as_str()),
})
.collect();
if names.is_empty() {
return "none".to_owned();
}
names.sort_unstable();
names.iter().map(|name| format!("{{{name}}}")).collect::<Vec<_>>().join(" ")
}
fn comparable<'m>(given: &'m Message, wanted: &'m Message) -> Vec<(PluralCategory, &'m Template, &'m Template)> {
let (given, wanted) = match (given, wanted) {
(Message::Plain(template), Message::Plural(_)) => (vec![(PluralCategory::Other, template)], written(wanted)),
(table @ Message::Plural(_), Message::Plain(template)) => {
(written(table), vec![(PluralCategory::Other, template)])
}
(given, wanted) => (written(given), written(wanted)),
};
wanted
.iter()
.filter_map(|(category, wanted)| {
let (_, given) = given.iter().copied().find(|(known, _)| known == category)?;
Some((*category, given, *wanted))
})
.collect()
}
fn written(message: &Message) -> Vec<(PluralCategory, &Template)> {
match message {
Message::Plain(template) => vec![(PluralCategory::Other, template)],
Message::Plural(forms) => forms.iter().map(|(category, template)| (*category, template)).collect(),
}
}
fn form_problems(files: &[(&str, Locale)]) -> Vec<Problem> {
let mut problems = Vec::new();
for (_, locale) in files {
let needed = needed_forms(&locale.code);
for (key, message) in &locale.messages {
let Message::Plural(forms) = message else { continue };
for (category, count) in &needed {
if !forms.contains_key(category) {
let message =
format!("`{key}` has no `{}` form, which {} uses for {count}", category.name(), locale.code);
problems.push(Problem::at(&place_of(locale, key).key, key, message));
}
}
}
}
problems
}
const COUNTS: RangeInclusive<i64> = 0..=200;
fn needed_forms(language: &str) -> Vec<(PluralCategory, i64)> {
let mut needed = Vec::new();
for category in PluralCategory::ALL {
if let Some(count) = COUNTS.clone().find(|n| PluralCategory::of(language, *n) == category) {
needed.push((category, count));
}
}
needed
}
fn share(reference: &Locale, locale: &Locale) -> (usize, usize) {
let (mut same, mut total) = (0, 0);
for (key, message) in &locale.messages {
let (Message::Plain(given), Some(Message::Plain(wanted))) = (message, reference.messages.get(key)) else {
continue;
};
let given = given.words();
if by_nature(&given) {
continue;
}
total += 1;
if given == wanted.words() {
same += 1;
}
}
(same, total)
}
fn by_nature(words: &str) -> bool {
!words.chars().any(char::is_alphabetic) || shortcut(words)
}
fn shortcut(words: &str) -> bool {
words.contains('+') && words.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '_' | '.' | '/'))
}
#[cfg(test)]
mod tests {
use super::*;
const EN: &str = r#"[meta]
name = "English"
code = "en"
[app]
save = "Save"
count = { one = "{n} file", other = "{n} files" }
key = "ctrl+s"
"#;
const TR: &str = r#"[meta]
name = "Türkçe"
code = "tr"
fallback = "en"
[app]
save = "Kaydet"
count = { one = "{n} dosya", other = "{n} dosya" }
key = "ctrl+s"
"#;
const RU: &str = r#"[meta]
name = "Русский"
code = "ru"
[app]
save = "Сохранить"
count = { one = "{n} файл", few = "{n} файла", many = "{n} файлов", other = "{n} файлов" }
key = "ctrl+s"
"#;
fn shown(problems: &[Problem]) -> Vec<(String, usize, Option<String>, String)> {
problems
.iter()
.map(|problem| (problem.file.clone(), problem.line, problem.key.clone(), problem.message.clone()))
.collect()
}
#[test]
fn a_complete_pair_of_files_has_no_problems() {
let files = [("en.toml", EN), ("tr.toml", TR)];
assert_eq!(shown(&locales(&files)), Vec::new());
assert_eq!(shown(&same_keys(&files)), Vec::new());
assert_eq!(shown(&same_placeholders(&files)), Vec::new());
assert_eq!(shown(&plural_forms(&files)), Vec::new());
assert_eq!(shown(&own_words(&files, 0.8)), Vec::new(), "Turkish wrote a word of its own");
let russian = [("en.toml", EN), ("ru.toml", RU)];
assert_eq!(shown(&plural_forms(&russian)), Vec::new());
assert_eq!(shown(&locales(&russian)), Vec::new());
}
#[test]
fn a_missing_key_is_named_at_the_line_of_the_reference() {
let tr = TR.replace("save = \"Kaydet\"\n", "");
let files = [("en.toml", EN), ("tr.toml", tr.as_str())];
let problems = same_keys(&files);
assert_eq!(
shown(&problems),
vec![(
"en.toml".to_owned(),
6,
Some("app.save".to_owned()),
"`app.save` is missing from `tr.toml`".to_owned()
)]
);
assert_eq!(problems[0].to_string(), "en.toml:6:1: `app.save` is missing from `tr.toml`");
assert_eq!(locales(&files).len(), 1, "and once only through `locales`");
}
#[test]
fn a_key_the_reference_does_not_have_is_named_at_its_own_line() {
let tr = TR.replace("key = \"ctrl+s\"\n", "key = \"ctrl+s\"\nquit = \"Çık\"\n");
let files = [("en.toml", EN), ("tr.toml", tr.as_str())];
assert_eq!(
shown(&same_keys(&files)),
vec![(
"tr.toml".to_owned(),
10,
Some("app.quit".to_owned()),
"`app.quit` is in `tr.toml` but not in `en.toml`, so nothing draws it".to_owned()
)]
);
}
#[test]
fn a_language_may_add_the_month_names_its_own_rule_needs() {
let ru = RU.replace("key = \"ctrl+s\"\n", "key = \"ctrl+s\"\n\n[quvyta.date]\nmonth-in-date-1 = \"января\"\n");
let files = [("en.toml", EN), ("ru.toml", ru.as_str())];
assert_eq!(shown(&same_keys(&files)), Vec::new());
}
#[test]
fn a_placeholder_written_under_another_name_is_named() {
let tr = TR.replace("{n} dosya", "{count} dosya");
let files = [("en.toml", EN), ("tr.toml", tr.as_str())];
assert_eq!(
shown(&same_placeholders(&files)),
vec![
(
"tr.toml".to_owned(),
8,
Some("app.count".to_owned()),
"`app.count`'s `one` form takes the placeholders {count} where the reference takes {n}".to_owned()
),
(
"tr.toml".to_owned(),
8,
Some("app.count".to_owned()),
"`app.count`'s `other` form takes the placeholders {count} where the reference takes {n}"
.to_owned()
),
]
);
}
#[test]
fn a_placeholder_left_out_of_one_form_only_is_named_there() {
let en = EN.replace("other = \"{n} files\"", "other = \"{n} files in {dir}\"");
let files = [("en.toml", en.as_str()), ("tr.toml", TR)];
let problems = same_placeholders(&files);
assert_eq!(problems.len(), 1, "only the form that differs: {problems:?}");
assert!(problems[0].message.contains("{dir}"), "{}", problems[0]);
assert!(!problems[0].message.contains("`one` form"), "the form beside it is untouched: {}", problems[0]);
}
#[test]
fn a_language_that_counts_in_three_ways_needs_three_forms() {
let ru = RU.replace("few = \"{n} файла\", ", "");
let files = [("en.toml", EN), ("ru.toml", ru.as_str())];
assert_eq!(
shown(&plural_forms(&files)),
vec![(
"ru.toml".to_owned(),
7,
Some("app.count".to_owned()),
"`app.count` has no `few` form, which ru uses for 2".to_owned()
)]
);
}
#[test]
fn a_language_may_write_a_plain_string_where_the_reference_has_a_table() {
let tr = TR.replace("count = { one = \"{n} dosya\", other = \"{n} dosya\" }", "count = \"{n} dosya\"");
let files = [("en.toml", EN), ("tr.toml", tr.as_str())];
assert_eq!(shown(&plural_forms(&files)), Vec::new());
assert_eq!(shown(&locales(&files)), Vec::new(), "the placeholders still have to match");
}
#[test]
fn a_file_copied_from_the_reference_is_complete_and_says_nothing_of_its_own() {
let turkish = EN.replace("name = \"English\"", "name = \"Türkçe\"").replace("code = \"en\"", "code = \"tr\"");
let files = [("en.toml", EN), ("tr.toml", turkish.as_str())];
assert_eq!(shown(&locales(&files)), Vec::new(), "every key, placeholder and form is there");
assert_eq!(
shown(&own_words(&files, 0.8)),
vec![(
"tr.toml".to_owned(),
1,
None,
"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"
.to_owned()
)]
);
}
#[test]
fn the_values_that_are_the_same_in_every_language_are_left_out_of_the_share() {
let en = EN.replace("key = \"ctrl+s\"", "key = \"ctrl+s\"\nsize = \"{n} {unit}\"\nfirst-weekday = \"1\"");
let tr = TR.replace("key = \"ctrl+s\"", "key = \"ctrl+s\"\nsize = \"{n} {unit}\"\nfirst-weekday = \"1\"");
let files = [("en.toml", en.as_str()), ("tr.toml", tr.as_str())];
assert_eq!(shown(&own_words(&files, 0.8)), Vec::new(), "nothing was left to translate");
let bare = "[meta]\nname = \"Türkçe\"\ncode = \"tr\"\n\n[app]\nkey = \"ctrl+s\"\nsize = \"{n} {unit}\"\n";
let alone = [("en.toml", en.as_str()), ("tr.toml", bare)];
assert_eq!(shown(&own_words(&alone, 0.8)), Vec::new());
assert_eq!(shown(&same_keys(&alone)).len(), 3, "app.save, app.count and app.first-weekday");
}
#[test]
fn a_broken_file_is_a_problem_and_not_a_panic() {
let files = [("en.toml", EN), ("tr.toml", "[meta]\nname = \"Türkçe\"\ncode = \n")];
for problems in [
locales(&files),
same_keys(&files),
same_placeholders(&files),
plural_forms(&files),
own_words(&files, 0.8),
] {
assert_eq!(problems.len(), 1, "{problems:?}");
assert_eq!(problems[0].file, "tr.toml");
assert_eq!(problems[0].line, 3, "the line the parser stopped at: {}", problems[0]);
}
let nameless = [("en.toml", EN), ("tr.toml", "[app]\nsave = \"Kaydet\"\n")];
let problems = same_keys(&nameless);
assert_eq!(problems.len(), 1, "{problems:?}");
assert!(problems[0].message.contains("[meta]"), "{}", problems[0]);
let unreadable = [("en.toml", "[meta]\nname = \n"), ("tr.toml", TR)];
let problems = same_keys(&unreadable);
assert_eq!(problems.len(), 1, "{problems:?}");
assert_eq!(problems[0].file, "en.toml");
}
#[test]
fn the_frameworks_own_languages_pass_every_check() {
let named: Vec<(String, &str)> =
crate::assets::LOCALES.iter().map(|(code, text)| (format!("{code}.toml"), *text)).collect();
let english = named.iter().position(|(name, _)| name == "en.toml").expect("English is one of them");
let mut ordered = named.clone();
ordered.swap(english, 0);
let files: Vec<(&str, &str)> = ordered.iter().map(|(name, text)| (name.as_str(), *text)).collect();
let problems = locales(&files);
assert!(problems.is_empty(), "{}", problems.iter().map(ToString::to_string).collect::<Vec<_>>().join("\n"));
let own = own_words(&files, 0.8);
assert!(own.is_empty(), "{}", own.iter().map(ToString::to_string).collect::<Vec<_>>().join("\n"));
}
}