use std::collections::HashMap;
use crate::checker::Checker;
use crate::diagnostic::{Diagnostic, Severity};
use crate::po::entry::Entry;
use crate::po::format::iter::FormatWordPos;
use crate::po::message::Message;
use crate::rules::rule::RuleChecker;
pub struct NoTransRule;
impl RuleChecker for NoTransRule {
fn name(&self) -> &'static str {
"no-trans"
}
fn description(&self) -> &'static str {
"Check that words listed in `no-trans-file` appear in translation, with the source case."
}
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 Some(no_trans_words) = checker.no_trans_words.as_ref() else {
return vec![];
};
let mut id_counts: HashMap<String, usize> = HashMap::new();
for word in FormatWordPos::new(&msgid.value, entry.format_language) {
if no_trans_words.contains(&word.s.to_lowercase()) {
*id_counts.entry(word.s.to_string()).or_insert(0) += 1;
}
}
if id_counts.is_empty() {
return vec![];
}
let mut str_counts: HashMap<String, usize> = HashMap::new();
let str_words: Vec<_> = FormatWordPos::new(&msgstr.value, entry.format_language).collect();
for word in &str_words {
if id_counts.contains_key(word.s) {
*str_counts.entry(word.s.to_string()).or_insert(0) += 1;
}
}
let mut diffs: Vec<(String, usize, usize)> = id_counts
.into_iter()
.filter_map(|(word, id_count)| {
let str_count = str_counts.get(&word).copied().unwrap_or(0);
(str_count != id_count).then_some((word, id_count, str_count))
})
.collect();
diffs.sort_by(|a, b| a.0.cmp(&b.0));
let mut diags = vec![];
for (word, id_count, str_count) in diffs {
let id_hl: Vec<(usize, usize)> =
FormatWordPos::new(&msgid.value, entry.format_language)
.filter(|w| w.s == word)
.map(|w| (w.start, w.end))
.collect();
let str_hl: Vec<(usize, usize)> = str_words
.iter()
.filter(|w| w.s == word)
.map(|w| (w.start, w.end))
.collect();
diags.extend(
self.new_diag(
checker,
Severity::Warning,
format!(
"word '{word}' must not be translated ({id_count} in source, {str_count} in translation)"
),
)
.map(|d| d.with_msgs_hl(msgid, id_hl, msgstr, str_hl)),
);
}
diags
}
}
#[cfg(test)]
mod tests {
use std::path::{Path, PathBuf};
use super::*;
use crate::{config::Config, diagnostic::Diagnostic, rules::rule::Rules};
fn write_no_trans_file(content: &str) -> (tempfile::TempDir, PathBuf) {
let tmp =
tempfile::TempDir::with_prefix("poexam-no-trans-").expect("create no-trans temp dir");
let path = tmp.path().join("no-trans.txt");
std::fs::write(&path, content).expect("write no-trans file");
(tmp, path)
}
fn check_no_trans(no_trans_file: &Path, content: &str) -> Vec<Diagnostic> {
let mut config = Config::default();
config.check.no_trans_file = Some(no_trans_file.to_path_buf());
let mut checker = Checker::new(content.as_bytes()).with_config(config);
let rules = Rules::new(vec![Box::new(NoTransRule {})]);
checker.do_all_checks(&rules);
checker.diagnostics
}
#[test]
fn test_no_match_in_source_is_ok() {
let (_tmp, no_trans) = write_no_trans_file("linux\n");
let diags = check_no_trans(
&no_trans,
r#"
msgid "hello world"
msgstr "bonjour le monde"
"#,
);
assert!(diags.is_empty());
}
#[test]
fn test_present_with_matching_case_is_ok() {
let (_tmp, no_trans) = write_no_trans_file("linux\n");
let diags = check_no_trans(
&no_trans,
r#"
msgid "Linux is great"
msgstr "Linux est génial"
"#,
);
assert!(diags.is_empty());
}
#[test]
fn test_missing_in_translation_is_flagged() {
let (_tmp, no_trans) = write_no_trans_file("linux\n");
let diags = check_no_trans(
&no_trans,
r#"
msgid "Linux is great"
msgstr "ceci est génial"
"#,
);
assert_eq!(diags.len(), 1);
assert_eq!(diags[0].severity, Severity::Warning);
assert_eq!(
diags[0].message,
"word 'Linux' must not be translated (1 in source, 0 in translation)"
);
}
#[test]
fn test_different_case_in_translation_is_flagged() {
let (_tmp, no_trans) = write_no_trans_file("linux\n");
let diags = check_no_trans(
&no_trans,
r#"
msgid "Linux is great"
msgstr "linux est génial"
"#,
);
assert_eq!(diags.len(), 1);
assert_eq!(
diags[0].message,
"word 'Linux' must not be translated (1 in source, 0 in translation)"
);
}
#[test]
fn test_word_list_case_does_not_matter() {
let (_tmp, no_trans) = write_no_trans_file("LINUX\n");
let diags = check_no_trans(
&no_trans,
r#"
msgid "Linux is great"
msgstr "Linux est génial"
"#,
);
assert!(diags.is_empty());
}
#[test]
fn test_count_mismatch_is_flagged() {
let (_tmp, no_trans) = write_no_trans_file("linux\n");
let diags = check_no_trans(
&no_trans,
r#"
msgid "Linux and Linux"
msgstr "Linux"
"#,
);
assert_eq!(diags.len(), 1);
assert_eq!(
diags[0].message,
"word 'Linux' must not be translated (2 in source, 1 in translation)"
);
}
#[test]
fn test_position_independent_count_match_is_ok() {
let (_tmp, no_trans) = write_no_trans_file("linux\n");
let diags = check_no_trans(
&no_trans,
r#"
msgid "Linux is Linux"
msgstr "Linux Linux est ok"
"#,
);
assert!(diags.is_empty());
}
#[test]
fn test_different_cases_in_source_each_tracked_separately() {
let (_tmp, no_trans) = write_no_trans_file("linux\n");
let diags = check_no_trans(
&no_trans,
r#"
msgid "Linux and LINUX"
msgstr "Linux and LINUX"
"#,
);
assert!(diags.is_empty());
}
#[test]
fn test_different_cases_in_source_partial_translation_is_flagged() {
let (_tmp, no_trans) = write_no_trans_file("linux\n");
let diags = check_no_trans(
&no_trans,
r#"
msgid "Linux and LINUX"
msgstr "Linux and Linux"
"#,
);
assert_eq!(diags.len(), 2);
let mut msgs: Vec<&str> = diags.iter().map(|d| d.message.as_ref()).collect();
msgs.sort_unstable();
assert_eq!(
msgs,
vec![
"word 'LINUX' must not be translated (1 in source, 0 in translation)",
"word 'Linux' must not be translated (1 in source, 2 in translation)",
]
);
}
#[test]
fn test_multiple_distinct_no_trans_words() {
let (_tmp, no_trans) = write_no_trans_file("linux\nposix\n");
let diags = check_no_trans(
&no_trans,
r#"
msgid "Linux and POSIX"
msgstr "ceci est génial"
"#,
);
assert_eq!(diags.len(), 2);
let mut msgs: Vec<&str> = diags.iter().map(|d| d.message.as_ref()).collect();
msgs.sort_unstable();
assert_eq!(
msgs,
vec![
"word 'Linux' must not be translated (1 in source, 0 in translation)",
"word 'POSIX' must not be translated (1 in source, 0 in translation)",
]
);
}
#[test]
fn test_comments_and_blank_lines_in_no_trans_file_are_ignored() {
let (_tmp, no_trans) = write_no_trans_file("# header\n\nlinux\n # trailing\n");
let diags = check_no_trans(
&no_trans,
r#"
msgid "Linux is great"
msgstr "Linux est génial"
"#,
);
assert!(diags.is_empty());
}
#[test]
fn test_noqa_suppresses_no_trans() {
let (_tmp, no_trans) = write_no_trans_file("linux\n");
let diags = check_no_trans(
&no_trans,
r#"
#, noqa:no-trans
msgid "Linux is great"
msgstr "ceci est génial"
"#,
);
assert!(diags.is_empty());
}
#[test]
fn test_missing_no_trans_file_emits_warning_and_skips_checks() {
let missing = PathBuf::from("/this/path/should/not/exist/no-trans.txt");
let diags = check_no_trans(
&missing,
r#"
msgid "Linux is great"
msgstr "ceci est génial"
"#,
);
assert_eq!(diags.len(), 1);
assert_eq!(diags[0].rule, "no-trans");
assert_eq!(diags[0].severity, Severity::Warning);
assert!(diags[0].message.contains("words file not found"));
assert!(diags[0].message.contains("no-trans rule ignored"));
assert!(diags[0].message.contains("no-trans.txt"));
}
}