use std::collections::HashSet;
use crate::checker::Checker;
use crate::diagnostic::{Diagnostic, Severity};
use crate::po::entry::Entry;
use crate::po::format::iter::FormatAcronymPos;
use crate::po::message::Message;
use crate::rules::rule::RuleChecker;
pub struct AcronymsRule;
impl RuleChecker for AcronymsRule {
fn name(&self) -> &'static str {
"acronyms"
}
fn description(&self) -> &'static str {
"Check that acronyms (all-uppercase words of length ≥ 2) from the source appear as-is in the translation."
}
fn is_default(&self) -> bool {
false
}
fn is_check(&self) -> bool {
true
}
fn check_msg(
&self,
checker: &Checker,
entry: &Entry,
msgid: &Message,
msgstr: &Message,
) -> Vec<Diagnostic> {
let force_words = checker.force_trans_words.as_ref();
let mut id_acronyms: HashSet<String> = HashSet::new();
for word in FormatAcronymPos::new(&msgid.value, entry.format_language) {
if force_words.is_some_and(|words| words.contains(&word.s.to_lowercase())) {
continue;
}
id_acronyms.insert(word.s.to_string());
}
if id_acronyms.is_empty() {
return vec![];
}
let str_acronyms: HashSet<&str> =
FormatAcronymPos::new(&msgstr.value, entry.format_language)
.map(|w| w.s)
.collect();
let mut missing: Vec<String> = id_acronyms
.into_iter()
.filter(|a| !str_acronyms.contains(a.as_str()))
.collect();
missing.sort_unstable();
let mut diags = vec![];
for acronym in missing {
let id_hl: Vec<(usize, usize)> =
FormatAcronymPos::new(&msgid.value, entry.format_language)
.filter(|w| w.s == acronym)
.map(|w| (w.start, w.end))
.collect();
diags.extend(
self.new_diag(
checker,
Severity::Warning,
format!("acronym '{acronym}' must not be translated"),
)
.map(|d| d.with_msgs_hl(msgid, id_hl, msgstr, [])),
);
}
diags
}
}
#[cfg(test)]
mod tests {
use std::path::PathBuf;
use super::*;
use crate::{config::Config, diagnostic::Diagnostic, rules::rule::Rules};
fn check_acronyms(content: &str) -> Vec<Diagnostic> {
let mut checker = Checker::new(content.as_bytes());
let rules = Rules::new(vec![Box::new(AcronymsRule {})]);
checker.do_all_checks(&rules);
checker.diagnostics
}
fn check_acronyms_with_force_trans(force_words: &str, content: &str) -> Vec<Diagnostic> {
let tmp = tempfile::TempDir::with_prefix("poexam-acronyms-force-")
.expect("create force-trans temp dir");
let force_path = tmp.path().join("force.txt");
std::fs::write(&force_path, force_words).expect("write force-trans file");
let mut config = Config::default();
config.check.force_trans_file = Some(force_path);
let mut checker = Checker::new(content.as_bytes()).with_config(config);
let rules = Rules::new(vec![
Box::new(AcronymsRule {}),
Box::new(crate::rules::force_trans::ForceTransRule {}),
]);
checker.do_all_checks(&rules);
checker.diagnostics
}
#[test]
fn test_no_acronym_in_source_is_ok() {
let diags = check_acronyms(
r#"
msgid "hello world"
msgstr "bonjour le monde"
"#,
);
assert!(diags.is_empty());
}
#[test]
fn test_acronym_present_in_translation_is_ok() {
let diags = check_acronyms(
r#"
msgid "Use the HTTP API"
msgstr "Utiliser l'API HTTP"
"#,
);
assert!(diags.is_empty());
}
#[test]
fn test_acronym_missing_in_translation_is_flagged() {
let diags = check_acronyms(
r#"
msgid "Use the HTTP protocol"
msgstr "Utiliser le protocole"
"#,
);
assert_eq!(diags.len(), 1);
assert_eq!(diags[0].severity, Severity::Warning);
assert_eq!(diags[0].message, "acronym 'HTTP' must not be translated");
}
#[test]
fn test_multiple_missing_acronyms_each_reported_once() {
let diags = check_acronyms(
r#"
msgid "Use the HTTP and JSON for the API"
msgstr "Utiliser le protocole et le format pour l'interface"
"#,
);
assert_eq!(diags.len(), 3);
let mut msgs: Vec<&str> = diags.iter().map(|d| d.message.as_ref()).collect();
msgs.sort_unstable();
assert_eq!(
msgs,
vec![
"acronym 'API' must not be translated",
"acronym 'HTTP' must not be translated",
"acronym 'JSON' must not be translated",
]
);
}
#[test]
fn test_one_diagnostic_per_acronym_even_with_multiple_occurrences() {
let diags = check_acronyms(
r#"
msgid "HTTP first, HTTP second"
msgstr "premier protocole, second protocole"
"#,
);
assert_eq!(diags.len(), 1);
assert_eq!(diags[0].message, "acronym 'HTTP' must not be translated");
let id_line = diags[0].lines.first().expect("msgid line");
assert_eq!(id_line.highlights.len(), 2);
}
#[test]
fn test_short_uppercase_word_is_not_an_acronym() {
let diags = check_acronyms(
r#"
msgid "Section A is here"
msgstr "La section est ici"
"#,
);
assert!(diags.is_empty());
}
#[test]
fn test_mixed_case_word_is_not_an_acronym() {
let diags = check_acronyms(
r#"
msgid "Use the Http protocol"
msgstr "Utiliser le protocole"
"#,
);
assert!(diags.is_empty());
}
#[test]
fn test_word_with_digit_is_an_acronym() {
let diags = check_acronyms(
r#"
msgid "Play MP3 files"
msgstr "Lire les fichiers audio"
"#,
);
assert_eq!(diags.len(), 1);
assert_eq!(diags[0].message, "acronym 'MP3' must not be translated");
}
#[test]
fn test_word_with_digit_preserved_in_translation_is_ok() {
let diags = check_acronyms(
r#"
msgid "Play MP3 files"
msgstr "Lire les fichiers MP3"
"#,
);
assert!(diags.is_empty());
}
#[test]
fn test_pure_digit_word_is_not_an_acronym() {
let diags = check_acronyms(
r#"
msgid "Code 123 found"
msgstr "Trouvé"
"#,
);
assert!(diags.is_empty());
}
#[test]
fn test_acronym_case_must_match_exactly() {
let diags = check_acronyms(
r#"
msgid "Open the URL"
msgstr "Ouvrir l'url"
"#,
);
assert_eq!(diags.len(), 1);
assert_eq!(diags[0].message, "acronym 'URL' must not be translated");
}
#[test]
fn test_format_strings_are_skipped() {
let diags = check_acronyms(
r#"
#, c-format
msgid "HTTP error: %s"
msgstr "erreur de protocole: %s"
"#,
);
assert_eq!(diags.len(), 1);
assert_eq!(diags[0].message, "acronym 'HTTP' must not be translated");
}
#[test]
fn test_force_trans_words_are_ignored_by_acronyms() {
let diags = check_acronyms_with_force_trans(
"url\n",
r#"
msgid "Open the URL now"
msgstr "Ouvrir l'adresse maintenant"
"#,
);
assert!(diags.is_empty(), "expected no diagnostics, got {diags:?}");
}
#[test]
fn test_force_trans_does_not_affect_other_acronyms() {
let diags = check_acronyms_with_force_trans(
"url\n",
r#"
msgid "Open the URL via HTTP"
msgstr "Ouvrir l'adresse via le protocole"
"#,
);
assert_eq!(diags.len(), 1);
assert_eq!(diags[0].message, "acronym 'HTTP' must not be translated");
}
#[test]
fn test_force_trans_not_loaded_when_file_missing_still_checks_acronyms() {
let mut config = Config::default();
config.check.force_trans_file = Some(PathBuf::from("/no/such/force.txt"));
let mut checker = Checker::new(
br#"
msgid "Open the URL"
msgstr "Ouvrir l'adresse"
"#,
)
.with_config(config);
let rules = Rules::new(vec![
Box::new(AcronymsRule {}),
Box::new(crate::rules::force_trans::ForceTransRule {}),
]);
checker.do_all_checks(&rules);
let acronym_diags: Vec<_> = checker
.diagnostics
.iter()
.filter(|d| d.rule == "acronyms")
.collect();
assert_eq!(acronym_diags.len(), 1);
assert_eq!(
acronym_diags[0].message,
"acronym 'URL' must not be translated"
);
}
#[test]
fn test_noqa_suppresses_acronyms() {
let diags = check_acronyms(
r#"
#, noqa:acronyms
msgid "Use the HTTP protocol"
msgstr "Utiliser le protocole"
"#,
);
assert!(diags.is_empty());
}
}