use fluent_syntax::ast::{ CallArguments, Entry, Expression, InlineExpression, Pattern, PatternElement, }; use fluent_syntax::parser; use std::collections::{BTreeMap, BTreeSet}; use std::fs; use std::path::{Path, PathBuf}; use syn::punctuated::Punctuated; use syn::visit::Visit; use syn::{Attribute, Expr, ExprCall, ExprMethodCall, ItemFn, ItemMod, Lit, Token}; fn pattern_variables(pattern: &Pattern<&str>, variables: &mut BTreeSet) { for element in &pattern.elements { if let PatternElement::Placeable { expression } = element { expression_variables(expression, variables); } } } fn arguments_variables(arguments: &CallArguments<&str>, variables: &mut BTreeSet) { for argument in &arguments.positional { inline_variables(argument, variables); } for argument in &arguments.named { inline_variables(&argument.value, variables); } } fn inline_variables(expression: &InlineExpression<&str>, variables: &mut BTreeSet) { match expression { InlineExpression::VariableReference { id } => { variables.insert(id.name.to_owned()); } InlineExpression::FunctionReference { arguments, .. } => { arguments_variables(arguments, variables); } InlineExpression::TermReference { arguments: Some(arguments), .. } => arguments_variables(arguments, variables), InlineExpression::Placeable { expression } => { expression_variables(expression, variables); } InlineExpression::StringLiteral { .. } | InlineExpression::NumberLiteral { .. } | InlineExpression::MessageReference { .. } | InlineExpression::TermReference { arguments: None, .. } => {} } } fn expression_variables(expression: &Expression<&str>, variables: &mut BTreeSet) { match expression { Expression::Inline(expression) => inline_variables(expression, variables), Expression::Select { selector, variants } => { inline_variables(selector, variables); for variant in variants { pattern_variables(&variant.value, variables); } } } } fn catalog_signature(source: &str) -> BTreeMap> { let resource = parser::parse(source).unwrap_or_else(|(_, errors)| { panic!("invalid Fluent catalog: {errors:?}"); }); let mut signature = BTreeMap::new(); for entry in resource.body { let (prefix, id, value, attributes) = match entry { Entry::Message(message) => ("", message.id, message.value, message.attributes), Entry::Term(term) => ("-", term.id, Some(term.value), term.attributes), Entry::Comment(_) | Entry::GroupComment(_) | Entry::ResourceComment(_) | Entry::Junk { .. } => continue, }; let key = format!("{prefix}{}", id.name); let mut variables = BTreeSet::new(); if let Some(value) = value { pattern_variables(&value, &mut variables); } assert!( signature.insert(key.clone(), variables).is_none(), "duplicate {key}" ); for attribute in attributes { let mut variables = BTreeSet::new(); pattern_variables(&attribute.value, &mut variables); let key = format!("{key}.{}", attribute.id.name); assert!( signature.insert(key.clone(), variables).is_none(), "duplicate {key}" ); } } signature } fn assert_catalog_matches( domain: &str, locale: &str, expected: &BTreeMap>, source: &str, ) { let actual = catalog_signature(source); let missing = expected .keys() .filter(|key| !actual.contains_key(*key)) .collect::>(); let extra = actual .keys() .filter(|key| !expected.contains_key(*key)) .collect::>(); let mismatched_variables = expected .iter() .filter_map(|(key, variables)| { actual .get(key) .filter(|actual| *actual != variables) .map(|actual| (key, variables, actual)) }) .collect::>(); assert!( missing.is_empty() && extra.is_empty() && mismatched_variables.is_empty(), "{domain}/{locale}.ftl: missing={missing:?}, extra={extra:?}, variable mismatches={mismatched_variables:?}" ); } #[test] fn every_locale_has_the_same_messages_and_variables_as_english() { let ui = catalog_signature(include_str!("../../../locales/ui/en.ftl")); for (locale, source) in [ ("de", include_str!("../../../locales/ui/de.ftl")), ("fr", include_str!("../../../locales/ui/fr.ftl")), ("it", include_str!("../../../locales/ui/it.ftl")), ("es", include_str!("../../../locales/ui/es.ftl")), ] { assert_catalog_matches("ui", locale, &ui, source); } let render = catalog_signature(include_str!("../../../locales/render/en.ftl")); for (locale, source) in [ ("de", include_str!("../../../locales/render/de.ftl")), ("fr", include_str!("../../../locales/render/fr.ftl")), ("it", include_str!("../../../locales/render/it.ftl")), ("es", include_str!("../../../locales/render/es.ftl")), ] { assert_catalog_matches("render", locale, &render, source); } } fn cfg_test(attributes: &[Attribute]) -> bool { attributes.iter().any(|attribute| { attribute.path().is_ident("test") || attribute.path().is_ident("cfg") && attribute .meta .require_list() .is_ok_and(|list| list.tokens.to_string().contains("test")) }) } fn is_user_facing(value: &str) -> bool { if matches!( value, "bDS" | "https://" | "https://api.example.com/v1" | "gpt-4.1-mini" | "sk-..." | "http://localhost:11434/v1" | "llama3.2" ) { return false; } let mut outside_placeholder = String::new(); let mut depth = 0; for character in value.chars() { match character { '{' => depth += 1, '}' if depth > 0 => depth -= 1, _ if depth == 0 => outside_placeholder.push(character), _ => {} } } outside_placeholder .split(|character: char| !character.is_alphabetic()) .filter(|word| !word.is_empty()) .any(|word| !matches!(word, "B" | "KB" | "MB" | "GB")) } #[derive(Default)] struct LiteralCollector { literals: Vec, } impl<'ast> Visit<'ast> for LiteralCollector { fn visit_expr_call(&mut self, call: &'ast ExprCall) { if let Expr::Path(path) = call.func.as_ref() && path.path.segments.last().is_some_and(|segment| { matches!( segment.ident.to_string().as_str(), "t" | "tw" | "translate" | "translate_with" ) }) { return; } syn::visit::visit_expr_call(self, call); } fn visit_expr_lit(&mut self, expression: &'ast syn::ExprLit) { if let Lit::Str(literal) = &expression.lit && is_user_facing(&literal.value()) { self.literals.push(literal.value()); } } fn visit_expr_macro(&mut self, expression: &'ast syn::ExprMacro) { let name = expression .mac .path .segments .last() .map(|segment| segment.ident.to_string()); if name .as_deref() .is_some_and(|name| matches!(name, "t" | "tw")) { return; } if name .as_deref() .is_some_and(|name| matches!(name, "format" | "format_args" | "concat")) && let Ok(arguments) = expression .mac .parse_body_with(Punctuated::::parse_terminated) { for argument in &arguments { self.visit_expr(argument); } } } } fn collect_literals(expression: &Expr) -> Vec { let mut collector = LiteralCollector::default(); collector.visit_expr(expression); collector.literals } #[derive(Default)] struct UiLiteralVisitor { violations: Vec, } impl UiLiteralVisitor { fn check(&mut self, sink: &str, expression: &Expr) { self.violations.extend( collect_literals(expression) .into_iter() .map(|literal| format!("{sink}: {literal:?}")), ); } } impl<'ast> Visit<'ast> for UiLiteralVisitor { fn visit_item_mod(&mut self, item: &'ast ItemMod) { if !cfg_test(&item.attrs) { syn::visit::visit_item_mod(self, item); } } fn visit_item_fn(&mut self, item: &'ast ItemFn) { if !cfg_test(&item.attrs) { syn::visit::visit_item_fn(self, item); } } fn visit_expr_call(&mut self, call: &'ast ExprCall) { if let Expr::Path(path) = call.func.as_ref() { let segments = path.path.segments.iter().collect::>(); let last = segments.last().map(|segment| segment.ident.to_string()); let previous = segments .get(segments.len().saturating_sub(2)) .map(|segment| segment.ident.to_string()); let indices: &[usize] = match (previous.as_deref(), last.as_deref()) { (Some("Toast"), Some("new")) => &[1], (Some("Submenu" | "MenuItem"), Some("new")) => &[0], ( _, Some( "text" | "help_text" | "section_header" | "labeled_checkbox" | "date_label" | "pick_folder", ), ) => &[0], (_, Some("text_input" | "labeled_input" | "pick_media_files")) => &[0, 1], _ => &[], }; for index in indices { if let Some(argument) = call.args.get(*index) { self.check(last.as_deref().unwrap_or("call"), argument); } } } syn::visit::visit_expr_call(self, call); } fn visit_expr_method_call(&mut self, call: &'ast ExprMethodCall) { let method = call.method.to_string(); let index = match method.as_str() { "notify" => Some(1), "add_output" | "set_title" | "set_description" | "set_text" | "submit" => Some(0), "report_progress" => Some(2), _ => None, }; if let Some(index) = index && let Some(argument) = call.args.get(index) { self.check(&method, argument); } syn::visit::visit_expr_method_call(self, call); } } fn rust_files(directory: &Path, files: &mut Vec) { for entry in fs::read_dir(directory).unwrap() { let path = entry.unwrap().path(); if path.is_dir() { rust_files(&path, files); } else if path.extension().is_some_and(|extension| extension == "rs") { files.push(path); } } } #[test] fn ui_text_sinks_do_not_receive_untranslated_literals() { let source_root = Path::new(env!("CARGO_MANIFEST_DIR")).join("src"); let mut files = Vec::new(); rust_files(&source_root, &mut files); let mut violations = Vec::new(); for path in files { let source = fs::read_to_string(&path).unwrap(); let syntax = syn::parse_file(&source).unwrap_or_else(|error| { panic!("failed to parse {}: {error}", path.display()); }); let mut visitor = UiLiteralVisitor::default(); visitor.visit_file(&syntax); violations.extend( visitor .violations .into_iter() .map(|violation| format!("{}: {violation}", path.display())), ); } assert!( violations.is_empty(), "user-facing literals must use t()/tw():\n{}", violations.join("\n") ); }