use std::collections::{BTreeMap, BTreeSet};
use super::{DEFAULT_LOCALE, Locale, Messages};
pub type TranslatedCatalogue = BTreeMap<String, TranslatedEntry>;
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct TranslatedEntry {
pub en: String,
pub t: String,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum RejectionReason {
SourceDrift,
PlaceholderMismatch,
ArgumentTypeMismatch,
PluralCategoryMissing,
OrphanKey,
Empty,
}
impl RejectionReason {
pub fn as_str(self) -> &'static str {
match self {
Self::SourceDrift => "source-drift",
Self::PlaceholderMismatch => "placeholder-mismatch",
Self::ArgumentTypeMismatch => "argument-type-mismatch",
Self::PluralCategoryMissing => "plural-category-missing",
Self::OrphanKey => "orphan-key",
Self::Empty => "empty",
}
}
}
impl std::fmt::Display for RejectionReason {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct Rejection {
pub key: String,
pub reason: RejectionReason,
pub detail: String,
}
#[derive(Clone, Debug, PartialEq)]
pub struct ResolvedCatalogue {
pub locale: Locale,
pub messages: Messages,
pub rejected: Vec<Rejection>,
pub missing: Vec<String>,
pub coverage: f64,
}
pub fn resolve_catalogue(locale: Locale, source: &Messages, translated: &TranslatedCatalogue) -> ResolvedCatalogue {
if locale == DEFAULT_LOCALE {
return ResolvedCatalogue {
locale,
messages: source.clone(),
rejected: Vec::new(),
missing: Vec::new(),
coverage: 1.0,
};
}
let mut messages = Messages::new();
let mut rejected = Vec::new();
let mut missing = Vec::new();
let mut accepted = 0usize;
for key in translated.keys() {
if !source.contains_key(key) {
rejected.push(Rejection {
key: key.clone(),
reason: RejectionReason::OrphanKey,
detail: format!("not defined in {DEFAULT_LOCALE} — only the canonical locale introduces keys"),
});
}
}
for (key, en) in source {
let Some(entry) = translated.get(key) else {
missing.push(key.clone());
messages.insert(key.clone(), en.clone());
continue;
};
match check(entry, en, locale) {
Some((reason, detail)) => {
rejected.push(Rejection { key: key.clone(), reason, detail });
messages.insert(key.clone(), en.clone());
}
None => {
messages.insert(key.clone(), entry.t.clone());
accepted += 1;
}
}
}
let total = source.len();
let coverage = if total == 0 { 1.0 } else { accepted as f64 / total as f64 };
ResolvedCatalogue {
locale,
messages,
rejected,
missing,
coverage,
}
}
pub fn audit(resolved: &[ResolvedCatalogue], floor: f64) -> (bool, String) {
let mut lines = Vec::new();
let mut ok = true;
for cat in resolved {
let pct = (cat.coverage * 100.0).round() as i64;
let healthy = cat.rejected.is_empty() && cat.coverage >= floor;
if !healthy {
ok = false;
}
lines.push(format!("{} {} {pct}% coverage", if healthy { "ok " } else { "FAIL" }, cat.locale));
for r in &cat.rejected {
lines.push(format!(" {} [{}] {}", r.key, r.reason, r.detail));
}
if !cat.missing.is_empty() {
let shown: Vec<&str> = cat.missing.iter().take(10).map(String::as_str).collect();
lines.push(format!(" untranslated ({}): {}", cat.missing.len(), shown.join(", ")));
if cat.missing.len() > shown.len() {
lines.push(format!(" …and {} more", cat.missing.len() - shown.len()));
}
}
}
(ok, lines.join("\n"))
}
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub enum MissingContentPolicy {
#[default]
Hide,
Fallback,
}
pub fn available_in<T, F>(locale: Locale, items: &[T], locales_of: F, policy: MissingContentPolicy) -> Vec<&T>
where
F: Fn(&T) -> &[Locale], {
if locale == DEFAULT_LOCALE || policy == MissingContentPolicy::Fallback {
return items.iter().collect();
}
items.iter().filter(|item| locales_of(item).contains(&locale)).collect()
}
fn check(entry: &TranslatedEntry, en: &str, locale: Locale) -> Option<(RejectionReason, String)> {
if entry.t.trim().is_empty() {
return Some((RejectionReason::Empty, "translation is blank".to_owned()));
}
if entry.en != en {
return Some((RejectionReason::SourceDrift, format!("translated from {:?}, source is now {en:?}", entry.en)));
}
let source_args = scan_arguments(en);
let target_args = scan_arguments(&entry.t);
for (name, kind) in &source_args {
let Some(mirrored) = target_args.get(name) else {
return Some((RejectionReason::PlaceholderMismatch, format!("source interpolates {{{name}}}, translation does not")));
};
if mirrored.arg_type != kind.arg_type {
return Some((
RejectionReason::ArgumentTypeMismatch,
format!("{{{name}}} is a {} in {DEFAULT_LOCALE} and a {} here", kind.arg_type, mirrored.arg_type),
));
}
if kind.arg_type == ArgType::Plural {
let absent: Vec<&str> = locale.plural_categories().iter().map(|c| c.as_str()).filter(|c| !mirrored.branches.contains(*c)).collect();
if !absent.is_empty() {
return Some((RejectionReason::PluralCategoryMissing, format!("{{{name}}} needs {} in {locale}", absent.join(", "))));
}
}
}
for name in target_args.keys() {
if !source_args.contains_key(name) {
return Some((
RejectionReason::PlaceholderMismatch,
format!("translation interpolates {{{name}}}, which the source does not provide"),
));
}
}
None
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum ArgType {
Value,
Plural,
Select,
}
impl std::fmt::Display for ArgType {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(match self {
Self::Value => "value",
Self::Plural => "plural",
Self::Select => "select",
})
}
}
#[derive(Clone, Debug)]
struct ArgumentShape {
arg_type: ArgType,
branches: BTreeSet<String>,
}
fn scan_arguments(pattern: &str) -> BTreeMap<String, ArgumentShape> {
let mut found = BTreeMap::new();
walk(&pattern.chars().collect::<Vec<char>>(), &mut found);
found
}
fn walk(text: &[char], found: &mut BTreeMap<String, ArgumentShape>) {
let mut i = 0;
while i < text.len() {
let ch = text[i];
if ch == '\'' {
let next = text.get(i + 1).copied();
if next == Some('\'') {
i += 2;
continue;
}
if matches!(next, Some('{') | Some('}') | Some('#')) {
i += 2;
while i < text.len() && text[i] != '\'' {
i += 1;
}
i += 1;
continue;
}
i += 1;
continue;
}
if ch != '{' {
i += 1;
continue;
}
let Some(end) = closing(text, i) else { return };
let inner = &text[i + 1..end];
i = end + 1;
let Some(first_comma) = top_level(inner, ',') else {
let name: String = inner.iter().collect::<String>().trim().to_owned();
if !name.is_empty() {
found.entry(name).or_insert(ArgumentShape {
arg_type: ArgType::Value,
branches: BTreeSet::new(),
});
}
continue;
};
let name: String = inner[..first_comma].iter().collect::<String>().trim().to_owned();
let rest = &inner[first_comma + 1..];
let second_comma = top_level(rest, ',');
let declared: String = match second_comma {
Some(idx) => rest[..idx].iter().collect::<String>(),
None => rest.iter().collect::<String>(),
}
.trim()
.to_owned();
let body: &[char] = match second_comma {
Some(idx) => &rest[idx + 1..],
None => &[],
};
let arg_type = match declared.as_str() {
"plural" => ArgType::Plural,
"select" => ArgType::Select,
_ => ArgType::Value,
};
let mut branches = BTreeSet::new();
if arg_type != ArgType::Value {
for (branch_key, branch_body) in branches_of(body) {
branches.insert(branch_key);
walk(&branch_body, found);
}
}
if !name.is_empty() {
found.insert(name, ArgumentShape { arg_type, branches });
}
}
}
fn closing(source: &[char], start: usize) -> Option<usize> {
let mut depth = 0usize;
for (offset, ch) in source[start..].iter().enumerate() {
match ch {
'{' => depth += 1,
'}' => {
depth -= 1;
if depth == 0 {
return Some(start + offset);
}
}
_ => {}
}
}
None
}
fn top_level(source: &[char], sep: char) -> Option<usize> {
let mut depth = 0i32;
for (i, &ch) in source.iter().enumerate() {
match ch {
'{' => depth += 1,
'}' => depth -= 1,
c if c == sep && depth == 0 => return Some(i),
_ => {}
}
}
None
}
fn branches_of(body: &[char]) -> Vec<(String, Vec<char>)> {
let mut out = Vec::new();
let mut i = 0;
while i < body.len() {
while i < body.len() && body[i].is_whitespace() {
i += 1;
}
let key_start = i;
while i < body.len() && !body[i].is_whitespace() && body[i] != '{' {
i += 1;
}
let key: String = body[key_start..i].iter().collect();
while i < body.len() && body[i].is_whitespace() {
i += 1;
}
if body.get(i) != Some(&'{') {
break;
}
let Some(end) = closing(body, i) else { break };
if !key.is_empty() {
out.push((key, body[i + 1..end].to_vec()));
}
i = end + 1;
}
out
}