diff --git a/askama/Cargo.toml b/askama/Cargo.toml index da53a9898..ed61a396e 100644 --- a/askama/Cargo.toml +++ b/askama/Cargo.toml @@ -38,6 +38,8 @@ askama_escape = { version = "0.10", path = "../askama_escape" } askama_shared = { version = "0.11", path = "../askama_shared", default-features = false } mime = { version = "0.3", optional = true } mime_guess = { version = "2.0.0-alpha", optional = true } +unic-langid = "0.9.0" +fluent-templates = "0.5.16" [package.metadata.docs.rs] features = ["config", "humansize", "num-traits", "serde-json", "serde-yaml"] diff --git a/askama/src/lib.rs b/askama/src/lib.rs index 601fb16ac..ff6767db7 100644 --- a/askama/src/lib.rs +++ b/askama/src/lib.rs @@ -135,3 +135,86 @@ pub mod mime { note = "file-level dependency tracking is handled automatically without build script" )] pub fn rerun_if_templates_changed() {} + +#[macro_export] +macro_rules! init_translation { + ( + $v: vis $n: ident { + static_loader_name: $static_loader_name: ident, + locales: $locales: expr, + fallback_language: $fallback_language: expr, + customise: $customise: expr + } + ) => { + use fluent_templates::Loader; + fluent_templates::static_loader! { + // Declare our `StaticLoader` named `LOCALES`. + static $static_loader_name = { + // The directory of localisations and fluent resources. + locales: $locales, + // The language to falback on if something is not present. + fallback_language: $fallback_language, + // Optional: A fluent resource that is shared with every locale. + //core_locales: "/core.ftl", + // Removes unicode isolating marks around arguments, you typically + // should only set to false when testing. + customise: $customise, + }; + } + $v struct $n { + language: unic_langid::LanguageIdentifier, + loader: &'static fluent_templates::once_cell::sync::Lazy + } + impl $n { + pub fn new(language: unic_langid::LanguageIdentifier) -> $n { + $n { + language, + loader: & $static_loader_name + } + } + pub fn default() -> $n { + $n { + language: unic_langid::langid!($fallback_language), + loader: & $static_loader_name + } + } + } + impl $n { + fn get_fallback_language(&self) -> unic_langid::LanguageIdentifier { + unic_langid::langid!($fallback_language) + } + + fn get_language(&self) -> unic_langid::LanguageIdentifier { + self.language.clone() + } + + fn translate( + &self, + text_id: &str, + args: + &std::collections::HashMap>, + ) -> String { + self.loader.lookup_with_args(&self.language, text_id, args) + } + + fn has_default_translation(&self, m: &str) -> bool { + // lookup_single_language panic's when invalid args are given + std::panic::set_hook(Box::new(|_info| { + // do nothing + })); + + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + self.loader.lookup_single_language(&self.get_fallback_language(), m, None) + })); + + let _ = std::panic::take_hook(); + + match result { + Ok(None) => false, + _ => true + } + } + } + + } +} diff --git a/askama_derive/src/lib.rs b/askama_derive/src/lib.rs index 101b86127..a93e8fb08 100644 --- a/askama_derive/src/lib.rs +++ b/askama_derive/src/lib.rs @@ -12,7 +12,7 @@ use proc_macro2::Span; use std::collections::HashMap; use std::path::PathBuf; -#[proc_macro_derive(Template, attributes(template))] +#[proc_macro_derive(Template, attributes(template, localizer))] pub fn derive_template(input: TokenStream) -> TokenStream { let ast: syn::DeriveInput = syn::parse(input).unwrap(); match build_template(&ast) { diff --git a/askama_shared/src/generator.rs b/askama_shared/src/generator.rs index 801db37fb..c44144842 100644 --- a/askama_shared/src/generator.rs +++ b/askama_shared/src/generator.rs @@ -10,7 +10,7 @@ use proc_macro2::Span; use quote::{quote, ToTokens}; -use std::collections::HashMap; +use std::collections::{BTreeSet, HashMap}; use std::path::PathBuf; use std::{cmp, hash, mem, str}; @@ -48,6 +48,8 @@ struct Generator<'a, S: std::hash::BuildHasher> { buf_writable: Vec>, // Counter for write! hash named arguments named: usize, + // Messages used with localize() + localized_messages: BTreeSet, } impl<'a, S: std::hash::BuildHasher> Generator<'a, S> { @@ -69,6 +71,7 @@ impl<'a, S: std::hash::BuildHasher> Generator<'a, S> { super_block: None, buf_writable: vec![], named: 0, + localized_messages: BTreeSet::new(), } } @@ -94,6 +97,7 @@ impl<'a, S: std::hash::BuildHasher> Generator<'a, S> { self.impl_template(ctx, &mut buf)?; self.impl_display(&mut buf)?; + self.impl_tests(&mut buf)?; if self.integrations.actix { self.impl_actix_web_responder(&mut buf)?; @@ -211,6 +215,53 @@ impl<'a, S: std::hash::BuildHasher> Generator<'a, S> { buf.writeln("}") } + // Implement Tests + fn impl_tests(&mut self, buf: &mut Buffer) -> Result<(), CompileError> { + buf.writeln(&format!( + "#[cfg(test)] mod __{}_tests_generated {{", + self.input.ast.ident.to_string().to_lowercase() + ))?; + // TODO + //if cfg!(feature = "with-i18n") { + self.impl_i18n_tests(buf)?; + //} + + buf.writeln("}") + } + + fn impl_i18n_tests(&mut self, buf: &mut Buffer) -> Result<(), CompileError> { + let messages = &self.localized_messages; + if messages.len() > 0 { + let loc_ty = self.input.localizer.as_ref().unwrap().1; + let ast = (quote! { + + + #[test] + fn test_i18n_default_coverage() { + let messages = &[ + #(#messages),* + ][..]; + + // create default localizer + let localizer = super::#loc_ty::default(); + + let bad = messages.iter().filter(|m| !localizer.has_default_translation(m)).collect::>(); + + if bad.len() > 0 { + panic!("Missing translations in default locale ({}) for messages: {:?} ", + localizer.get_language().to_string(), bad); + } + } + }) + .to_string(); + + buf.writeln(&ast) + } else { + Ok(()) + } + } + + // Implement Actix-web's `Responder`. fn impl_actix_web_responder(&mut self, buf: &mut Buffer) -> Result<(), CompileError> { self.write_header(buf, "::actix_web::Responder", None)?; @@ -1118,6 +1169,9 @@ impl<'a, S: std::hash::BuildHasher> Generator<'a, S> { self.visit_method_call(buf, obj, method, args)? } Expr::RustMacro(name, args) => self.visit_rust_macro(buf, name, args), + Expr::Localize(message, attribute, ref args) => { + self.visit_localize(buf, message, attribute, args)? + } }) } @@ -1367,6 +1421,60 @@ impl<'a, S: std::hash::BuildHasher> Generator<'a, S> { Ok(DisplayWrap::Unwrapped) } + fn visit_localize( + &mut self, + buf: &mut Buffer, + message: &str, + attribute: Option<&str>, + args: &[(&str, Expr)], + ) -> Result { + /* TODO + if !cfg!(feature = "with-i18n") { + panic!( + "The askama feature 'with-i18n' must be activated to enable calling `localize`." + ); + } + */ + + // TODO + let localizer = self.input.localizer.as_ref().expect( + "A template struct must have a member with the `#[localizer]` \ + attribute that implements `askama::Localize` to enable calling the localize() filter", + ); + + let mut message = message.to_string(); + if let Some(attribute) = attribute { + message.push_str("."); + message.push_str(attribute); + } + + assert!( + message.chars().find(|c| *c == '"').is_none(), + "message ids with quotes in them break the generator, please remove" + ); + + self.localized_messages.insert(message.clone()); + + buf.write(&format!( + "self.{}.translate(\"{}\", &std::iter::FromIterator::from_iter(vec![", + localizer.0, message + )); + + for (i, (name, value)) in args.iter().enumerate() { + if i > 0 { + buf.write(", "); + } + buf.write(&format!( + "(\"{}\".to_string(), ({}).into())", + name, + self.visit_expr_root(value)? + )); + } + buf.write("]))"); + + Ok(DisplayWrap::Unwrapped) + } + fn visit_unary( &mut self, buf: &mut Buffer, diff --git a/askama_shared/src/input.rs b/askama_shared/src/input.rs index ca4015aa9..a297a0aed 100644 --- a/askama_shared/src/input.rs +++ b/askama_shared/src/input.rs @@ -15,6 +15,7 @@ pub struct TemplateInput<'a> { pub ext: Option, pub parent: Option<&'a syn::Type>, pub path: PathBuf, + pub localizer: Option<(syn::Ident, &'a syn::Type)>, } impl<'a> TemplateInput<'a> { @@ -136,16 +137,37 @@ impl<'a> TemplateInput<'a> { // Check to see if a `_parent` field was defined on the context // struct, and store the type for it for use in the code generator. - let parent = match ast.data { + let (parent, localizer) = match ast.data { syn::Data::Struct(syn::DataStruct { fields: syn::Fields::Named(ref fields), .. - }) => fields - .named - .iter() - .find(|f| f.ident.as_ref().filter(|name| *name == "_parent").is_some()) - .map(|f| &f.ty), - _ => None, + }) => { + let named = &fields.named; + ( + named + .iter() + .find(|f| f.ident.as_ref().filter(|name| *name == "_parent").is_some()) + .map(|f| &f.ty), + { + let localizers: Vec<_> = named + .iter() + .filter(|f| f.ident.is_some()) + .flat_map(|f| { + f.attrs + .iter() + .filter(|a| a.path.is_ident("localizer")) + .map(move |_| (f.ident.to_owned().unwrap(), &f.ty)) + }) + .collect(); + if localizers.len() > 1 { + panic!("Can't have multiple localizers for a single template!"); + } else { + localizers.get(0).map(|l| l.to_owned()) + } + }, + ) + } + _ => (None, None), }; if parent.is_some() { @@ -196,6 +218,7 @@ impl<'a> TemplateInput<'a> { parent, path, syntax, + localizer, }) } } diff --git a/askama_shared/src/parser.rs b/askama_shared/src/parser.rs index 2b8e8f065..88e1ff0d9 100644 --- a/askama_shared/src/parser.rs +++ b/askama_shared/src/parser.rs @@ -49,6 +49,7 @@ pub enum Expr<'a> { Group(Box>), MethodCall(Box>, &'a str, Vec>), RustMacro(&'a str, &'a str), + Localize(&'a str, Option<&'a str>, Vec<(&'a str, Expr<'a>)>), } impl Expr<'_> { @@ -613,6 +614,29 @@ fn expr_rust_macro(i: &[u8]) -> IResult<&[u8], Expr> { Ok((i, Expr::RustMacro(mname, args))) } +fn localize(i: &[u8]) -> IResult<&[u8], Expr> { + let (i, (_, _, message, attribute, args, _)) = tuple(( + tag("localize"), + ws(tag("(")), + identifier, + opt(tuple((ws(tag(".")), identifier))), + opt(tuple(( + ws(tag(",")), + separated_list0(ws(tag(",")), tuple((identifier, ws(tag(":")), expr_any))), + ))), + ws(tag(")")), + ))(i)?; + Ok(( + i, + Expr::Localize( + message, + attribute.map(|(_, a)| a), + args.map(|(_, args)| args.into_iter().map(|(k, _, v)| (k, v)).collect()) + .unwrap_or_default(), + ), + )) +} + macro_rules! expr_prec_layer { ( $name:ident, $inner:ident, $op:expr ) => { fn $name(i: &[u8]) -> IResult<&[u8], Expr> { @@ -673,7 +697,7 @@ fn expr_any(i: &[u8]) -> IResult<&[u8], Expr> { Expr::Range(op, _, right) => Expr::Range(op, Some(Box::new(left)), right), _ => unreachable!(), }); - let mut p = alt((range_right, compound, expr_or)); + let mut p = alt((range_right, localize, compound, expr_or)); Ok(p(i)?) } @@ -1255,6 +1279,24 @@ mod tests { ); } + #[test] + fn test_parse_localize() { + assert_eq!( + super::parse("{{ localize(a, v: 32 + 7) }}", &Syntax::default()).unwrap(), + vec![Node::Expr( + WS(false, false), + Expr::Localize( + "a", + None, + vec![( + "v", + Expr::BinOp("+", Expr::NumLit("32").into(), Expr::NumLit("7").into()) + )] + ) + )] + ); + } + #[test] fn change_delimiters_parse_filter() { let syntax = Syntax { diff --git a/testing/Cargo.toml b/testing/Cargo.toml index 0fcee8c0d..90a0ddc8b 100644 --- a/testing/Cargo.toml +++ b/testing/Cargo.toml @@ -12,6 +12,8 @@ default = ["serde_json", "askama/serde-json"] [dependencies] askama = { path = "../askama", version = "*" } serde_json = { version = "1.0", optional = true } +fluent-templates = "0.5.16" +unic-langid = "0.9.0" [dev-dependencies] criterion = "0.3" diff --git a/testing/i18n-basic/en-US/basic.ftl b/testing/i18n-basic/en-US/basic.ftl new file mode 100644 index 000000000..dbcef06c0 --- /dev/null +++ b/testing/i18n-basic/en-US/basic.ftl @@ -0,0 +1,2 @@ +greeting = Hello, { $name }! +age = You are { $hours } hours old. diff --git a/testing/i18n-basic/es-MX/basic.ftl b/testing/i18n-basic/es-MX/basic.ftl new file mode 100644 index 000000000..b79a07d8d --- /dev/null +++ b/testing/i18n-basic/es-MX/basic.ftl @@ -0,0 +1,3 @@ +greeting = ¡Hola, { $name }! + +age = Tienes { $hours } horas. diff --git a/testing/templates/i18n.html b/testing/templates/i18n.html new file mode 100644 index 000000000..3d493273e --- /dev/null +++ b/testing/templates/i18n.html @@ -0,0 +1,2 @@ +

{{ localize(greeting, name: name) }}

+

{{ localize(age, hours: hours ) }}

diff --git a/testing/templates/i18n_invalid.html b/testing/templates/i18n_invalid.html new file mode 100644 index 000000000..4493d06fb --- /dev/null +++ b/testing/templates/i18n_invalid.html @@ -0,0 +1,2 @@ +

{{ localize(greetingsss, name: name) }}

+

{{ localize(ages) }}

diff --git a/testing/tests/i18n.rs b/testing/tests/i18n.rs new file mode 100644 index 000000000..86b9a23d3 --- /dev/null +++ b/testing/tests/i18n.rs @@ -0,0 +1,62 @@ +// TODO +/* +#![cfg(feature = "with-i18n")] +#![allow(unused)] +*/ + +use askama::init_translation; +use askama::Template; + +init_translation! { + pub MyLocalizer { + static_loader_name: LOCALES, + locales: "i18n-basic", + fallback_language: "en-US", + customise: |bundle| bundle.set_use_isolating(false) + } +} + +#[derive(Template)] +#[template(path = "i18n_invalid.html")] +struct UsesI18nInvalid<'a> { + #[localizer] + loc: MyLocalizer, + name: &'a str, +} + +#[derive(Template)] +#[template(path = "i18n.html")] +struct UsesI18n<'a> { + #[localizer] + loc: MyLocalizer, + name: &'a str, + hours: f64, +} + +#[test] +fn existing_language() { + let template = UsesI18n { + loc: MyLocalizer::new(unic_langid::langid!("es-MX")), + name: "Hilda", + hours: 300072.3, + }; + assert_eq!( + template.render().unwrap(), + r#"

¡Hola, Hilda!

+

Tienes 300072.3 horas.

"# + ) +} + +#[test] +fn unknown_language() { + let template = UsesI18n { + loc: MyLocalizer::new(unic_langid::langid!("nl-BE")), + name: "Hilda", + hours: 300072.3, + }; + assert_eq!( + template.render().unwrap(), + r#"

Hello, Hilda!

+

You are 300072.3 hours old.

"# + ) +}