Skip to content
This repository was archived by the owner on Mar 11, 2025. It is now read-only.
Draft

I18n #434

Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions askama/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
83 changes: 83 additions & 0 deletions askama/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The fallback language can be added to the toml file.

// 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<fluent_templates::StaticLoader>
Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We need to be able to access this loader somehow. That's the reason for this struct.

}
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, fluent_templates::fluent_bundle::FluentValue<'_>>,
) -> 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
}
}
}

}
}
2 changes: 1 addition & 1 deletion askama_derive/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
110 changes: 109 additions & 1 deletion askama_shared/src/generator.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};

Expand Down Expand Up @@ -48,6 +48,8 @@ struct Generator<'a, S: std::hash::BuildHasher> {
buf_writable: Vec<Writable<'a>>,
// Counter for write! hash named arguments
named: usize,
// Messages used with localize()
localized_messages: BTreeSet<String>,
}

impl<'a, S: std::hash::BuildHasher> Generator<'a, S> {
Expand All @@ -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(),
}
}

Expand All @@ -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)?;
Expand Down Expand Up @@ -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();
Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Here we need to be able to somehow access the static loader. Currently an object of the generated struct type is used for that.


let bad = messages.iter().filter(|m| !localizer.has_default_translation(m)).collect::<Vec<_>>();

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)?;
Expand Down Expand Up @@ -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)?
}
})
}

Expand Down Expand Up @@ -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<DisplayWrap, CompileError> {
/* 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![",
Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Here we need to be able to call the translate method. Currently it uses the Localizer object that has to be added to all Template structs.

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,
Expand Down
37 changes: 30 additions & 7 deletions askama_shared/src/input.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ pub struct TemplateInput<'a> {
pub ext: Option<String>,
pub parent: Option<&'a syn::Type>,
pub path: PathBuf,
pub localizer: Option<(syn::Ident, &'a syn::Type)>,
}

impl<'a> TemplateInput<'a> {
Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -196,6 +218,7 @@ impl<'a> TemplateInput<'a> {
parent,
path,
syntax,
localizer,
})
}
}
Expand Down
44 changes: 43 additions & 1 deletion askama_shared/src/parser.rs
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ pub enum Expr<'a> {
Group(Box<Expr<'a>>),
MethodCall(Box<Expr<'a>>, &'a str, Vec<Expr<'a>>),
RustMacro(&'a str, &'a str),
Localize(&'a str, Option<&'a str>, Vec<(&'a str, Expr<'a>)>),
}

impl Expr<'_> {
Expand Down Expand Up @@ -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> {
Expand Down Expand Up @@ -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)?)
}

Expand Down Expand Up @@ -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 {
Expand Down
Loading