1use 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#[derive(Debug, Clone, PartialEq, Eq)]
36#[non_exhaustive]
37pub struct Problem {
38 pub file: String,
40 pub line: usize,
43 pub column: usize,
45 pub key: Option<String>,
47 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#[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#[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#[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#[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#[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 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
171struct Loaded<'a> {
174 files: Vec<(&'a str, Locale)>,
176 problems: Vec<Problem>,
180}
181
182fn 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 if index == 0 && loaded.files.is_empty() {
194 break;
195 }
196 }
197 loaded
198}
199
200impl Problem {
201 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 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
213fn 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
219fn 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
242const OPTIONAL_FAMILIES: [&str; 1] = ["quvyta.date.month-in-date-"];
247
248fn 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
255fn 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 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
283fn 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
301fn 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
321fn 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
329fn 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
348const COUNTS: RangeInclusive<i64> = 0..=200;
351
352fn 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
363fn share(reference: &Locale, locale: &Locale) -> (usize, usize) {
366 let (mut same, mut total) = (0, 0);
367 for (key, message) in &locale.messages {
368 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
385fn by_nature(words: &str) -> bool {
389 !words.chars().any(char::is_alphabetic) || shortcut(words)
390}
391
392fn 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 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 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 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 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 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 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 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 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 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 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}