Files
ppsspp/Tools/langtool/src/validate.rs
T
Henrik RydgårdandClaude Opus 5 398d8238bd langtool: Validate translated strings, and check AI output before writing it
Placeholders like %1 and %d have to survive translation intact, and they don't
always: the new "validate" command finds 37 strings across four language files
where one got dropped, localized into another script, or split with a space.
It also catches empty translations, line breaks and stray quotes, and exits
non-zero so it can be used as a check in a script.

The same checks now run on everything the AI returns, before it's written to a
file - plus a check that it didn't just echo the English string back at us. A
language that fails is skipped instead of aborting the whole run.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0164UKoXpz9r155TRfQsoc3H
2026-08-23 16:18:07 +02:00

135 lines
4.9 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use regex::Regex;
use std::fmt;
use std::sync::LazyLock;
// %1-%9 are positional placeholders, %s/%d/%f are printf-style ones. Either way they need to
// survive translation intact, or we get garbage (or worse) on screen at runtime.
static PLACEHOLDER: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"%[0-9sdf]").unwrap());
#[derive(Debug, PartialEq, Eq)]
pub enum Issue {
Placeholders { expected: String, found: String },
Empty,
Newline,
Quoted,
Untranslated,
}
impl fmt::Display for Issue {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
match self {
Issue::Placeholders { expected, found } => {
write!(f, "placeholder mismatch, expected [{expected}], got [{found}]")
}
Issue::Empty => write!(f, "empty translation"),
Issue::Newline => write!(f, "contains a line break"),
Issue::Quoted => write!(f, "wrapped in quotes or a markdown fence"),
Issue::Untranslated => write!(f, "same as the English string"),
}
}
}
fn placeholders(str: &str) -> Vec<&str> {
let mut found: Vec<&str> = PLACEHOLDER.find_iter(str).map(|m| m.as_str()).collect();
found.sort_unstable();
found
}
/// Checks a translated string against the English one it was translated from. Cheap enough to
/// run over every string in every language file, see the Validate command.
///
/// Note that Untranslated is never returned here - plenty of strings are legitimately identical
/// to the English one, so only the AI paths check for it (see check_ai_translation).
pub fn check(reference: &str, translation: &str) -> Vec<Issue> {
let mut issues = vec![];
if translation.trim().is_empty() {
// Nothing else is going to be meaningful.
return vec![Issue::Empty];
}
if translation.contains('\n') || translation.contains('\r') {
issues.push(Issue::Newline);
}
let expected = placeholders(reference);
let found = placeholders(translation);
if expected != found {
issues.push(Issue::Placeholders {
expected: expected.join(" "),
found: found.join(" "),
});
}
// The AI likes to quote things, and the quotes are not part of the translation.
if is_quoted(translation.trim()) && !is_quoted(reference.trim()) {
issues.push(Issue::Quoted);
}
issues
}
/// Same as check(), but also rejects a translation that's just the English string echoed back,
/// which is a common enough AI failure that it's worth catching before we write it to a file
/// (and mark it as translated).
pub fn check_ai_translation(reference: &str, translation: &str) -> Vec<Issue> {
let mut issues = check(reference, translation);
if translation.trim() == reference.trim() {
issues.push(Issue::Untranslated);
}
issues
}
fn is_quoted(str: &str) -> bool {
(str.len() > 1 && str.starts_with('"') && str.ends_with('"')) || str.starts_with("```")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn accepts_normal_translations() {
assert!(check("Graphics", "Grafik").is_empty());
assert!(check("Slot %1", "Kortplats %1").is_empty());
assert!(check("%d (%d per core, %d cores)", "%d (%d per kärna, %d kärnor)").is_empty());
// Reordering placeholders is fine, plenty of languages need to.
assert!(check("Submitted %1 for %2", "%2 till %1 inskickad").is_empty());
// Percent signs that aren't placeholders shouldn't bother it.
assert!(check("Speed: 100%", "Hastighet: 100%").is_empty());
}
#[test]
fn catches_broken_placeholders() {
// A dropped placeholder, as seen in ko_KR before this was added.
assert_eq!(
check("Submitted %1 for %2", "2에 %1을(를) 제출함"),
vec![Issue::Placeholders {
expected: "%1 %2".to_string(),
found: "%1".to_string()
}]
);
// A localized digit, as seen in fa_IR.
assert!(!check("Quick chat %1", "گپ سریع ۱").is_empty());
// A space snuck in between the % and the digit, as seen all over km_KH.
assert!(!check("Slot %1", "រន្ធ% 1").is_empty());
// Same placeholder, wrong number of them.
assert!(!check("%d of %d", "%d").is_empty());
}
#[test]
fn catches_bad_ai_output() {
assert_eq!(check("Graphics", ""), vec![Issue::Empty]);
assert_eq!(check("Graphics", " "), vec![Issue::Empty]);
assert_eq!(check("Graphics", "\"Grafik\""), vec![Issue::Quoted]);
assert_eq!(check("Graphics", "```Grafik```"), vec![Issue::Quoted]);
assert_eq!(check("Graphics", "Grafik\nGrafik"), vec![Issue::Newline]);
assert_eq!(
check_ai_translation("Graphics", "Graphics"),
vec![Issue::Untranslated]
);
// But check() itself allows it, lots of strings are the same in both languages.
assert!(check("Graphics", "Graphics").is_empty());
}
}