Files
RuDS/crates/bds-ui/tests/i18n_completeness.rs

374 lines
12 KiB
Rust

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<String>) {
for element in &pattern.elements {
if let PatternElement::Placeable { expression } = element {
expression_variables(expression, variables);
}
}
}
fn arguments_variables(arguments: &CallArguments<&str>, variables: &mut BTreeSet<String>) {
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<String>) {
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<String>) {
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<String, BTreeSet<String>> {
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<String, BTreeSet<String>>,
source: &str,
) {
let actual = catalog_signature(source);
let missing = expected
.keys()
.filter(|key| !actual.contains_key(*key))
.collect::<Vec<_>>();
let extra = actual
.keys()
.filter(|key| !expected.contains_key(*key))
.collect::<Vec<_>>();
let mismatched_variables = expected
.iter()
.filter_map(|(key, variables)| {
actual
.get(key)
.filter(|actual| *actual != variables)
.map(|actual| (key, variables, actual))
})
.collect::<Vec<_>>();
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<String>,
}
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::<Expr, Token![,]>::parse_terminated)
{
for argument in &arguments {
self.visit_expr(argument);
}
}
}
}
fn collect_literals(expression: &Expr) -> Vec<String> {
let mut collector = LiteralCollector::default();
collector.visit_expr(expression);
collector.literals
}
#[derive(Default)]
struct UiLiteralVisitor {
violations: Vec<String>,
}
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::<Vec<_>>();
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<PathBuf>) {
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")
);
}