Skip to content

Commit

Permalink
feat: allow specifying fluent message attributes directly and via tera (
Browse files Browse the repository at this point in the history
#4)

* feat: allow specifying message attributes directly and via tera

* fix: add derives

* fix: make format methods work with &String

* fix: add tests

* fix: change attribute variable name
  • Loading branch information
jreppnow authored Jul 1, 2024
1 parent 3b851ef commit 056807a
Show file tree
Hide file tree
Showing 3 changed files with 145 additions and 36 deletions.
142 changes: 126 additions & 16 deletions src/fluent.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
use std::{collections::HashMap, fmt::Debug, path::Path};
use std::{collections::HashMap, error::Error, fmt::Debug, path::Path};

use fluent::{bundle::FluentBundle, types::FluentNumberOptions, FluentArgs, FluentResource};
use unic_langid::LanguageIdentifier;
Expand Down Expand Up @@ -104,7 +104,7 @@ impl Localizer {
/// Searches for a full locale match and returns it.
/// If no full locale match, returns a language match if available
pub fn get_locale(&self, locale: &LanguageIdentifier) -> Option<&Bundle> {
let full_locale_match = self.locales.get(&*locale);
let full_locale_match = self.locales.get(locale);

// Try to match only on the language if full match not found
match full_locale_match {
Expand All @@ -122,31 +122,67 @@ impl Localizer {
/// for details
///
/// Fluent template errors are printed to stdout.
pub fn format_message<'a>(
pub fn format_message(
&self,
locale: &LanguageIdentifier,
key: &str,
args: Option<&'a FluentArgs>,
key: &(impl MessageKey + ?Sized),
args: Option<&FluentArgs>,
) -> Option<String> {
let bundle = self.get_locale(locale)?;
self.format_message_result(locale, key, args).ok()
}

let message = bundle.get_message(key)?;
/// Format a FTL message into target locale if available.<br>
/// See Fluent RS [FluentBundle::format_pattern documentation](https://docs.rs/fluent/latest/fluent/bundle/struct.FluentBundle.html#method.format_pattern)
/// for details
///
/// Fluent template errors are printed to stdout.
pub fn format_message_result(
&self,
locale: &LanguageIdentifier,
key: &(impl MessageKey + ?Sized),
args: Option<&FluentArgs>,
) -> Result<String, Box<dyn Error + Send + Sync + 'static>> {
let bundle = self
.get_locale(locale)
.ok_or_else(|| format!("could not find locale {locale}"))?;

let pattern = message.value()?;
let message = bundle
.get_message(key.key())
.ok_or_else(|| format!("could not find message with key={}", key.key()))?;

let mut errors = Vec::new();

let message = bundle
.format_pattern(pattern, args, &mut errors)
.to_string();
let message = if let Some(attribute) = key.attribute() {
let attribute = message.get_attribute(attribute).ok_or_else(|| {
format!(
"could not find attribute={attribute} for message with key={}",
key.key()
)
})?;

if errors.len() > 0 {
for err in errors {
println!("{}", err.to_string());
}
bundle
.format_pattern(attribute.value(), args, &mut errors)
.to_string()
} else {
bundle
.format_pattern(
message.value().ok_or_else(|| {
format!(
"message with key={} does not have a standalone message",
key.key()
)
})?,
args,
&mut errors,
)
.to_string()
};

for err in errors {
println!("{err}");
}

Some(message)
Ok(message)
}

pub fn iter(&self) -> std::collections::hash_map::Iter<LanguageIdentifier, Bundle> {
Expand All @@ -160,6 +196,36 @@ impl Localizer {
}
}

pub trait MessageKey {
fn key(&self) -> &str;

fn attribute(&self) -> Option<&str> {
None
}
}

impl<S: AsRef<str> + ?Sized> MessageKey for S {
fn key(&self) -> &str {
self.as_ref()
}
}

#[derive(Debug, Clone, Copy)]
pub struct MessageAttribute<'key, 'attribute> {
pub key: &'key str,
pub attribute: &'attribute str,
}

impl<'key, 'attribute> MessageKey for MessageAttribute<'key, 'attribute> {
fn key(&self) -> &str {
self.key
}

fn attribute(&self) -> Option<&str> {
Some(self.attribute)
}
}

#[cfg(test)]
mod tests {
use super::*;
Expand Down Expand Up @@ -199,6 +265,50 @@ mod tests {
assert!(bundle.is_some());
}

#[test]
fn compiles_with_borrowed_string() {
let mut loc = Localizer::new();
loc.add_bundle(ENGLISH, &[MAIN, SUB]).unwrap();

loc.format_message(&ENGLISH, &"test-key-a".to_owned(), None);
}

#[test]
fn use_attributes() {
let mut loc = Localizer::new();
loc.add_bundle(ENGLISH, &[MAIN, SUB]).unwrap();

let message = loc
.format_message(
&ENGLISH,
&MessageAttribute {
key: "attribute-test",
attribute: "attribute_a",
},
None,
)
.expect("formatting succeeds");

assert_eq!("Hello", message)
}

#[test]
fn not_existing_attribute() {
let mut loc = Localizer::new();
loc.add_bundle(ENGLISH, &[MAIN, SUB]).unwrap();

assert!(loc
.format_message(
&ENGLISH,
&MessageAttribute {
key: "attribute-test",
attribute: "does_not_exist",
},
None,
)
.is_none());
}

#[test]
fn can_format_pattern() {
let mut loc = Localizer::new();
Expand Down
35 changes: 15 additions & 20 deletions src/tera.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
use std::{borrow::Cow, collections::HashMap};

use crate::Localizer;
use crate::{fluent::MessageAttribute, Localizer};
use fluent::{
types::{FluentNumber, FluentNumberOptions},
FluentArgs, FluentValue,
Expand All @@ -21,34 +21,29 @@ impl tera::Function for Localizer {
.and_then(|key| key.as_str())
.ok_or(tera::Error::msg("missing ftl key"))?;

let bundle = self.get_locale(&lang_arg).ok_or(tera::Error::msg(format!(
"locale not registered: {lang_arg}"
)))?;

let msg = bundle
.get_message(ftl_key)
.ok_or(tera::Error::msg(&format!(
"FTL key not in locale: {}",
ftl_key
)))?;
let pattern = msg
.value()
.ok_or(tera::Error::msg("No value in fluent message"))?;
let ftl_attribute = args.get("attribute").and_then(|attr| attr.as_str());

let fluent_args: FluentArgs = args
.iter()
.filter(|(key, _)| key.as_str() != "key")
.map(|(key, val)| (key, json_value_to_fluent_value(val, self.number_options())))
.collect();

let mut errs = Vec::new();
let res = bundle.format_pattern(pattern, Some(&fluent_args), &mut errs);

if errs.len() > 0 {
dbg!(errs);
let message = if let Some(ftl_attribute) = ftl_attribute {
self.format_message_result(
&lang_arg,
&MessageAttribute {
key: ftl_key,
attribute: ftl_attribute,
},
Some(&fluent_args),
)
} else {
self.format_message_result(&lang_arg, ftl_key, Some(&fluent_args))
}
.map_err(|err| tera::Error::chain("failed to format message", err))?;

Ok(serde_json::Value::String(res.into()))
Ok(serde_json::Value::String(message))
}

fn is_safe(&self) -> bool {
Expand Down
4 changes: 4 additions & 0 deletions test_data/main.ftl
Original file line number Diff line number Diff line change
@@ -1,2 +1,6 @@
test-key-a = Hello World
test-name = Peg { $name }
attribute-test =
.attribute_a = Hello
.attribute_b = there!

0 comments on commit 056807a

Please sign in to comment.