Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: allow specifying fluent message attributes directly and via tera #4

Merged
merged 5 commits into from
Jul 1, 2024
Merged
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
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)
}
}

jreppnow marked this conversation as resolved.
Show resolved Hide resolved
#[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();
Comment on lines +269 to +296
Copy link
Owner

Choose a reason for hiding this comment

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

This is great, also a good way to see how to use them in the wild 🦘👍.

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!
Loading