use std::fs;
use std::path::{Path, PathBuf};
use proc_macro2::TokenStream;
use quote::{format_ident, quote};
use sofie_check::{Finding, Rule, Screens};
use sofie_engine::{KeyUse, Template, TranslationError, Translations, check_translations};
use sofie_model::App;
use sofie_model::naming::{
IDENTITY_PACKAGE, SOURCE_LANGUAGE, TENANCY_PACKAGE, crate_name, file_stem, key_constant,
snake_case,
};
use crate::gen_command::{GenError, io_error, readable_screen};
use crate::sorted;
use crate::source_edits::files_with_extension;
pub(crate) struct AppLanguages {
languages: Vec<String>,
files: Vec<LanguageFile>,
packages: Vec<Package>,
}
pub(crate) struct LanguageFile {
module: String,
language: String,
path: PathBuf,
source: String,
}
struct Package {
crate_name: String,
files: Vec<LanguageFile>,
screens: Vec<PathBuf>,
}
impl AppLanguages {
pub(crate) fn read(app: &App) -> Result<Option<Self>, GenError> {
let mut files = Vec::new();
for module in sorted::modules(app) {
files.extend(language_files(&module.krate.crate_name, &module.languages)?);
}
if files.is_empty() {
return Ok(None);
}
let mut packages = Vec::new();
if app.identity.is_some() {
packages.push(read_package(app, IDENTITY_PACKAGE)?);
}
if app.depends_on(TENANCY_PACKAGE) {
packages.push(read_package(app, TENANCY_PACKAGE)?);
}
Ok(Some(Self::new(files, packages)))
}
fn new(files: Vec<LanguageFile>, packages: Vec<Package>) -> Self {
let named = files.iter().map(|file| file.language.clone());
let mut languages =
crate::sorted::first_seen(std::iter::once(SOURCE_LANGUAGE.to_owned()).chain(named));
languages[1..].sort();
Self {
languages,
files,
packages,
}
}
fn load(&self) -> Result<Translations, TranslationError> {
let languages: Vec<&str> = self.languages.iter().map(String::as_str).collect();
let mut translations = Translations::new(&languages)?;
for file in &self.files {
translations.add(&file.module, &file.language, &file.source)?;
}
for package in &self.packages {
let builtin: Vec<(&str, &str)> = package
.files
.iter()
.map(|file| (file.language.as_str(), file.source.as_str()))
.collect();
translations.add_builtin(&package.crate_name, &builtin)?;
}
Ok(translations)
}
pub(crate) fn findings(&self, app: &App, screens: &Screens) -> Result<Vec<Finding>, GenError> {
let translations = match self.load() {
Ok(translations) => translations,
Err(error) => return Ok(vec![self.load_finding(&error)]),
};
let package_templates = self.package_templates()?;
let package_templates = package_templates
.iter()
.map(|(file, template)| (file.as_path(), template));
let mut findings = Vec::new();
let mut uses: Vec<KeyUse> = Vec::new();
for (file, template) in module_templates(app, screens)?
.into_iter()
.chain(package_templates)
{
if let Err(error) = check_translations(template, &translations) {
findings.push(finding(
file.to_path_buf(),
error.line,
error.message.clone(),
));
}
uses.extend(sofie_engine::translation_keys(template));
}
for gap in translations.gaps(&uses) {
let file = self.file_for(&gap.language);
findings.push(finding(file, 1, gap.to_string()));
}
findings.extend(enum_clashes(app));
findings.extend(self.key_constant_clashes(app));
let known = translations.keys();
findings.extend(
app.modules
.iter()
.flat_map(|module| &module.code.translation_keys)
.filter(|literal| known.binary_search(&literal.key.as_str()).is_err())
.map(|literal| {
let message = format!(
"translation key `{}` is in no `lang/{SOURCE_LANGUAGE}.toml`, so its text \
would be the key itself",
literal.key
);
finding(literal.file.clone(), literal.line, message)
}),
);
Ok(findings)
}
fn key_constant_clashes(&self, app: &App) -> Vec<Finding> {
let mut findings = Vec::new();
for module in &app.modules {
let name = &module.krate.crate_name;
let keys = match self.module_keys(name) {
Ok(keys) => keys,
Err(error) => return vec![self.load_finding(&error)],
};
let file = || self.file_for_module(name);
let mut seen: Vec<(String, &str)> = Vec::new();
for key in &keys {
let constant = key_constant(key);
if syn::parse_str::<syn::Ident>(&constant).is_err() {
let message = format!(
"translation key `{key}` becomes the constant `keys::{constant}`, which is \
no Rust identifier; start each of its parts with a letter"
);
findings.push(finding(file(), 1, message));
} else if let Some((_, first)) = seen.iter().find(|(taken, _)| *taken == constant) {
let message = format!(
"translation keys `{first}` and `{key}` both become the constant \
`keys::{constant}`; rename one"
);
findings.push(finding(file(), 1, message));
} else {
seen.push((constant, key));
}
}
}
findings
}
fn file_for_module(&self, module: &str) -> PathBuf {
self.files
.iter()
.find(|file| file.module == module && file.language == SOURCE_LANGUAGE)
.map(|file| file.path.clone())
.unwrap_or_default()
}
pub(crate) fn module_keys(&self, module: &str) -> Result<Vec<String>, TranslationError> {
let Some(file) = self
.files
.iter()
.find(|file| file.module == module && file.language == SOURCE_LANGUAGE)
else {
return Ok(Vec::new());
};
let shipped = Translations::new(&[SOURCE_LANGUAGE])?;
let mut own = Translations::new(&[SOURCE_LANGUAGE])?;
own.add(module, SOURCE_LANGUAGE, &file.source)?;
let shipped = shipped.keys();
Ok(own
.keys()
.into_iter()
.filter(|key| shipped.binary_search(key).is_err())
.map(str::to_owned)
.collect())
}
fn package_templates(&self) -> Result<Vec<(PathBuf, Template)>, GenError> {
let mut templates = Vec::new();
for file in self.packages.iter().flat_map(|package| &package.screens) {
let source = fs::read_to_string(file).map_err(|source| io_error(file, source))?;
if let Ok(template) = Template::parse(&file.display().to_string(), &source) {
templates.push((file.clone(), template));
}
}
Ok(templates)
}
fn load_finding(&self, error: &TranslationError) -> Finding {
match error {
TranslationError::File {
file,
line,
message,
..
} => {
let path = self
.all_files()
.find(|known| &known.name() == file)
.map_or_else(|| PathBuf::from(file), |known| known.path.clone());
finding(path, *line, message.clone())
}
TranslationError::Declaration(message) => {
finding(self.file_for(SOURCE_LANGUAGE), 1, message.clone())
}
}
}
fn all_files(&self) -> impl Iterator<Item = &LanguageFile> {
let packages = self.packages.iter().flat_map(|package| &package.files);
self.files.iter().chain(packages)
}
fn file_for(&self, language: &str) -> PathBuf {
self.files
.iter()
.find(|file| file.language == language)
.or_else(|| self.files.first())
.map(|file| file.path.with_file_name(format!("{language}.toml")))
.unwrap_or_default()
}
pub(crate) fn translations_function(&self, app: &App) -> TokenStream {
let languages = &self.languages;
let files = self.files.iter().map(|file| {
let module = &file.module;
let file = crate::wiring::embedded_file(app, &file.path);
quote!((#module, #file))
});
let enums = app_enums(app).into_iter().map(|(name, variants)| {
let variants = variants.into_iter().map(|(variant, tone)| {
let tone = format_ident!("{tone}");
quote!((#variant, sofie::engine::Tone::#tone))
});
quote!((#name, &[#(#variants),*]))
});
let builtin = self.packages.iter().map(|package| {
let name = &package.crate_name;
let package = crate::wiring::path(name);
quote!((#name, #package::TRANSLATIONS))
});
quote! {
fn translations() -> Result<sofie::engine::Translations> {
sofie::http::translations(
&[#(#languages),*],
&[#(#files),*],
&[#(#builtin),*],
&[#(#enums),*],
)
}
}
}
}
fn app_enums(app: &App) -> Vec<(String, Vec<(String, String)>)> {
let mut enums = Vec::new();
for module in sorted::modules(app) {
for declared in &module.code.enums {
let variants = declared
.stored_names
.iter()
.zip(&declared.variants)
.map(|(stored, variant)| (stored.clone(), tone_variant(variant.tone.as_deref())))
.collect();
enums.push((snake_case(&declared.name), variants));
}
}
enums
}
fn tone_variant(tone: Option<&str>) -> String {
let mut letters = tone.unwrap_or("neutral").chars();
letters
.next()
.map(|first| first.to_uppercase().chain(letters).collect())
.unwrap_or_default()
}
fn enum_clashes(app: &App) -> Vec<Finding> {
let mut findings = Vec::new();
let mut seen: Vec<(String, &str)> = Vec::new();
for module in sorted::modules(app) {
for declared in &module.code.enums {
let name = snake_case(&declared.name);
if let Some((_, first)) = seen.iter().find(|(seen, _)| *seen == name) {
findings.push(Finding {
rule: Rule::Translations,
file: module.krate.path.join("src/lib.rs"),
line: 1,
message: format!(
"enum `{}` of module `{}` shares its `enum.{name}.*` keys with module \
`{first}`'s",
declared.name, module.krate.package
),
fix: "rename one of the two enums".to_owned(),
});
} else {
seen.push((name, &module.krate.package));
}
}
}
findings
}
fn finding(file: PathBuf, line: usize, message: String) -> Finding {
Finding {
rule: Rule::Translations,
file,
line,
message,
fix: "add the key to each language's `lang/<language>.toml`, with the same placeholders"
.to_owned(),
}
}
impl LanguageFile {
fn name(&self) -> String {
format!("{}/lang/{}.toml", self.module, self.language)
}
}
fn language_files(module: &str, paths: &[PathBuf]) -> Result<Vec<LanguageFile>, GenError> {
paths
.iter()
.cloned()
.map(|path| {
let source = fs::read_to_string(&path).map_err(|source| io_error(&path, source))?;
Ok(LanguageFile {
module: module.to_owned(),
language: file_stem(&path),
path,
source,
})
})
.collect()
}
fn module_templates<'s>(
app: &'s App,
screens: &'s Screens,
) -> Result<Vec<(&'s Path, &'s Template)>, GenError> {
let files = app.modules.iter().flat_map(|module| {
module.areas.iter().flat_map(|area| {
let overrides = area.overrides.iter().map(|replacement| &replacement.file);
area.screens.iter().chain(overrides)
})
});
let mut templates = Vec::new();
for file in files {
if let Ok(template) = &readable_screen(screens, file)?.template {
templates.push((file.as_path(), template));
}
}
Ok(templates)
}
fn read_package(app: &App, package: &str) -> Result<Package, GenError> {
let krate = sofie_model::dependency_crate(&app.root, package)
.map_err(GenError::Model)?
.ok_or_else(|| {
GenError::Model(sofie_model::ModelError::in_file(
&app.app.path.join("Cargo.toml"),
format!("the app uses {package} but cargo does not list it"),
))
})?;
let crate_name = crate_name(package);
Ok(Package {
files: language_files(
&crate_name,
&files_with_extension(&krate.path.join("lang"), "toml")?,
)?,
screens: files_with_extension(&krate.path.join("src/screens"), "html")?,
crate_name,
})
}
#[cfg(test)]
mod tests {
use sofie_model::testing::{app, module};
use super::*;
fn file(language: &str, source: &str) -> LanguageFile {
LanguageFile {
module: "order".to_owned(),
language: language.to_owned(),
path: PathBuf::from(format!("shop/modules/order/lang/{language}.toml")),
source: source.to_owned(),
}
}
fn rendered(tokens: &TokenStream) -> String {
crate::rustfmt::format_rust(&tokens.to_string(), std::path::Path::new("generated.rs"))
.unwrap()
}
#[test]
fn declares_the_source_language_then_each_file_s() {
let languages = AppLanguages::new(vec![file("tr", ""), file("de", "")], Vec::new());
let shop = app("shop", vec![module("order", "shop/modules/order")]);
let statement = rendered(&languages.translations_function(&shop));
assert!(
statement.contains("&[\"en\", \"de\", \"tr\"]"),
"{statement}"
);
assert!(
statement.contains("(\"order\", sofie::db::embed!(\"../modules/order/lang/tr.toml\"))"),
"{statement}"
);
}
#[test]
fn a_file_that_does_not_load_is_a_finding_at_that_file() {
let languages = AppLanguages::new(vec![file("tr", "[orders\n")], Vec::new());
let findings = languages
.findings(&app("shop", Vec::new()), &Screens::read(&[]))
.unwrap();
assert_eq!(findings.len(), 1);
assert_eq!(
findings[0].file,
PathBuf::from("shop/modules/order/lang/tr.toml")
);
assert_eq!(findings[0].line, 1);
}
#[test]
fn a_key_one_language_lacks_is_a_gap() {
let languages = AppLanguages::new(
vec![
file("de", "[orders]\ntitle = \"Bestellungen\"\n"),
file("tr", "[label]\nstatus = \"Durum\"\n"),
],
Vec::new(),
);
let findings = languages
.findings(&app("shop", Vec::new()), &Screens::read(&[]))
.unwrap();
let messages: Vec<String> = findings
.iter()
.map(|finding| finding.message.clone())
.collect();
assert!(
messages.contains(&"`orders.title` has no `en` translation".to_owned()),
"{messages:?}"
);
assert!(
messages.contains(&"`label.status` has no `de` translation".to_owned()),
"{messages:?}"
);
}
#[test]
fn a_key_whose_constant_is_no_identifier_or_taken_twice_is_a_finding() {
let languages = AppLanguages::new(
vec![file(
"en",
"orders_title = \"Orders\"\n[404]\ntitle = \"Not found\"\n[orders]\ntitle = \"Orders\"\n",
)],
Vec::new(),
);
let shop = app("shop", vec![module("order", "shop/modules/order")]);
let findings = languages.findings(&shop, &Screens::read(&[])).unwrap();
let messages: Vec<&str> = findings
.iter()
.map(|finding| finding.message.as_str())
.collect();
assert_eq!(messages.len(), 2, "{messages:?}");
assert!(messages[0].contains("`404.title`"), "{messages:?}");
assert!(messages[1].contains("`keys::ORDERS_TITLE`"), "{messages:?}");
}
#[test]
fn registers_each_module_enum_with_its_tones() {
let languages = AppLanguages::new(vec![file("tr", "")], Vec::new());
let mut order = module("order", "shop/modules/order");
let mut status =
sofie_model::testing::enumeration("OrderStatus", &[("Open", &[]), ("Paid", &[])]);
status.variants[1].tone = Some("success".to_owned());
order.code.enums.push(status);
let statement = rendered(&languages.translations_function(&app("shop", vec![order])));
assert!(
statement.contains(
"&[\n (\"OPEN\", sofie::engine::Tone::Neutral),\n \
(\"PAID\", sofie::engine::Tone::Success),\n ],"
),
"{statement}"
);
}
#[test]
fn an_app_with_mails_writes_them_in_its_languages_on_the_app_and_the_worker() {
let languages = AppLanguages::new(vec![file("tr", "")], Vec::new());
let mut order = module("order", "shop/modules/order");
order.mails = vec![sofie_model::MailTemplate {
key: "order/receipt".to_owned(),
file: PathBuf::from("shop/modules/order/src/mails/receipt.html"),
}];
let wiring = crate::wiring::render_wiring(&app("shop", vec![order]), Some(&languages), &[]);
let plain = crate::wiring::render_wiring(
&app("shop", vec![module("order", "shop/modules/order")]),
Some(&languages),
&[],
);
let registered =
"sofie :: mail :: translations (& db , std :: sync :: Arc :: new (translations ()";
assert_eq!(wiring.matches(registered).count(), 2, "{wiring}");
assert!(
wiring.contains("screens . translate (translations () ?)"),
"{wiring}"
);
assert!(wiring.contains("fn translations ()"), "{wiring}");
assert!(!plain.contains("sofie :: mail"), "{plain}");
}
#[test]
fn a_tone_starting_with_a_letter_of_several_bytes_is_capitalized_without_panicking() {
assert_eq!(tone_variant(Some("ünlü")), "Ünlü");
assert_eq!(tone_variant(None), "Neutral");
}
#[test]
fn a_key_rust_names_must_be_in_the_source_language() {
let languages =
AppLanguages::new(vec![file("en", "[order]\nlate = \"Late\"\n")], Vec::new());
let mut order = module("order", "shop/modules/order");
let literal = |key: &str, line| sofie_model::KeyLiteral {
key: key.to_owned(),
file: PathBuf::from("shop/modules/order/src/actions.rs"),
line,
};
order.code.translation_keys = vec![
literal("order.late", 3),
literal("validation.max", 4),
literal("order.early", 5),
];
let findings = languages
.findings(&app("shop", vec![order]), &Screens::read(&[]))
.unwrap();
assert_eq!(findings.len(), 1, "{findings:?}");
assert_eq!(findings[0].line, 5);
assert!(
findings[0].message.contains("`order.early`"),
"{}",
findings[0].message
);
}
}