diff --git a/fluent-bundle/examples/typesafe_messages.rs b/fluent-bundle/examples/typesafe_messages.rs new file mode 100644 index 00000000..b0c7bf36 --- /dev/null +++ b/fluent-bundle/examples/typesafe_messages.rs @@ -0,0 +1,142 @@ +// This is an example of an application which adds a custom argument resolver +// to add type safety. +// See the external_arguments example if you are not yet familiar with fluent arguments. +// +// The goal is that we prevent bugs caused by mixing up arguments that belong +// to different messages. +// We can achieve this by defining structs for each message that encode the +// argument types with the corresponding message ID, and then hooking into +// fluent's resolver using a custom fluent_bundle::ArgumentResolver implementation. + +use std::borrow::Cow; + +use fluent_bundle::{ArgumentResolver, FluentBundle, FluentError, FluentResource, FluentValue}; +use unic_langid::langid; + +fn main() { + let ftl_string = String::from( + " +hello-world = Hello { $name } +ref = The previous message says { hello-world } +unread-emails = + { $emailCount -> + [one] You have { $emailCount } unread email + *[other] You have { $emailCount } unread emails + } + ", + ); + let res = FluentResource::try_new(ftl_string).expect("Could not parse an FTL string."); + let langid_en = langid!("en"); + let mut bundle = FluentBundle::new(vec![langid_en]); + bundle + .add_resource(res) + .expect("Failed to add FTL resources to the bundle."); + + let hello_world = messages::HelloWorld { name: "John" }; + let mut errors = vec![]; + let value = bundle.format_message(&hello_world, &mut errors); + println!("{}", value); + + let ref_msg = messages::Ref { hello_world }; + let mut errors = vec![]; + let value = bundle.format_message(&ref_msg, &mut errors); + println!("{}", value); + + let unread_emails = messages::UnreadEmails { + email_count: Some(1), + }; + let mut errors = vec![]; + let value = bundle.format_message(&unread_emails, &mut errors); + println!("{}", value); +} + +// these definitions could be generated by a macro or a code generation tool +mod messages { + use super::*; + + pub struct HelloWorld<'a> { + pub name: &'a str, + } + + impl<'a> Message<'a> for HelloWorld<'a> { + fn id(&self) -> &'static str { + "hello-world" + } + + fn get_arg(&self, name: &str) -> Option> { + Some(match name { + "name" => self.name.into(), + _ => return None, + }) + } + } + + pub struct Ref<'a> { + pub hello_world: HelloWorld<'a>, + } + + impl<'a> Message<'a> for Ref<'a> { + fn id(&self) -> &'static str { + "ref" + } + + fn get_arg(&self, name: &str) -> Option> { + self.hello_world.get_arg(name) + } + } + + pub struct UnreadEmails { + pub email_count: Option, + } + + impl<'a> Message<'a> for UnreadEmails { + fn id(&self) -> &'static str { + "unread-emails" + } + + fn get_arg(&self, name: &str) -> Option> { + Some(match name { + "emailCount" => self.email_count.into(), + _ => return None, + }) + } + } +} + +trait Message<'a> { + fn id(&self) -> &'static str; + fn get_arg(&self, name: &str) -> Option>; +} + +// by using &dyn, we prevent monomorphization for each Message struct +// this keeps binary code size in check +impl<'a, 'b> ArgumentResolver<'a> for &'a dyn Message<'b> { + fn resolve(self, name: &str) -> Option>> { + let arg = self.get_arg(name)?; + Some(Cow::Owned(arg)) + } +} + +// allows for method syntax, i.e. bundle.format_message(...) +trait CustomizedBundle { + fn format_message<'b>( + &'b self, + message: &dyn Message, + errors: &mut Vec, + ) -> Cow<'b, str>; +} + +impl CustomizedBundle for FluentBundle { + fn format_message<'b>( + &'b self, + message: &dyn Message, + errors: &mut Vec, + ) -> Cow<'b, str> { + let msg = self + .get_message(message.id()) + .expect("Message doesn't exist."); + + let pattern = msg.value().expect("Message has no value."); + self.format_pattern_with_argument_resolver(pattern, message, errors) + } +} diff --git a/fluent-bundle/src/args.rs b/fluent-bundle/src/args.rs index 58a560e0..52ce13de 100644 --- a/fluent-bundle/src/args.rs +++ b/fluent-bundle/src/args.rs @@ -1,4 +1,3 @@ -use std::borrow::Cow; use std::iter::FromIterator; use crate::types::FluentValue; @@ -53,7 +52,7 @@ use crate::types::FluentValue; /// ); /// ``` #[derive(Debug, Default)] -pub struct FluentArgs<'args>(Vec<(Cow<'args, str>, FluentValue<'args>)>); +pub struct FluentArgs<'args>(Vec<(&'args str, FluentValue<'args>)>); impl<'args> FluentArgs<'args> { /// Creates a new empty argument map. @@ -67,12 +66,8 @@ impl<'args> FluentArgs<'args> { } /// Gets the [`FluentValue`] at the `key` if it exists. - pub fn get(&self, key: K) -> Option<&FluentValue<'args>> - where - K: Into>, - { - let key = key.into(); - if let Ok(idx) = self.0.binary_search_by_key(&&key, |(k, _)| k) { + pub fn get<'s>(&'s self, key: &str) -> Option<&'s FluentValue<'args>> { + if let Ok(idx) = self.0.binary_search_by_key(&key, |(k, _)| k) { Some(&self.0[idx].1) } else { None @@ -80,32 +75,33 @@ impl<'args> FluentArgs<'args> { } /// Sets the key value pair. - pub fn set(&mut self, key: K, value: V) + pub fn set(&mut self, key: &'args str, value: V) where - K: Into>, V: Into>, { - let key = key.into(); + self.set_inner(key, value.into()); + } + + fn set_inner(&mut self, key: &'args str, value: FluentValue<'args>) { match self.0.binary_search_by_key(&&key, |(k, _)| k) { - Ok(idx) => self.0[idx] = (key, value.into()), - Err(idx) => self.0.insert(idx, (key, value.into())), + Ok(idx) => self.0[idx] = (key, value), + Err(idx) => self.0.insert(idx, (key, value)), }; } /// Iterate over a tuple of the key an [`FluentValue`]. - pub fn iter(&self) -> impl Iterator { - self.0.iter().map(|(k, v)| (k.as_ref(), v)) + pub fn iter(&self) -> impl Iterator)> { + self.0.iter().map(|(k, v)| (*k, v)) } } -impl<'args, K, V> FromIterator<(K, V)> for FluentArgs<'args> +impl<'args, V> FromIterator<(&'args str, V)> for FluentArgs<'args> where - K: Into>, V: Into>, { fn from_iter(iter: I) -> Self where - I: IntoIterator, + I: IntoIterator, { let iter = iter.into_iter(); let mut args = if let Some(size) = iter.size_hint().1 { @@ -123,7 +119,7 @@ where } impl<'args> IntoIterator for FluentArgs<'args> { - type Item = (Cow<'args, str>, FluentValue<'args>); + type Item = (&'args str, FluentValue<'args>); type IntoIter = std::vec::IntoIter; fn into_iter(self) -> Self::IntoIter { @@ -133,6 +129,8 @@ impl<'args> IntoIterator for FluentArgs<'args> { #[cfg(test)] mod tests { + use std::borrow::Cow; + use super::*; #[test] diff --git a/fluent-bundle/src/bundle.rs b/fluent-bundle/src/bundle.rs index d198004d..0ba60887 100644 --- a/fluent-bundle/src/bundle.rs +++ b/fluent-bundle/src/bundle.rs @@ -24,6 +24,7 @@ use crate::message::FluentMessage; use crate::resolver::{ResolveValue, Scope, WriteValue}; use crate::resource::FluentResource; use crate::types::FluentValue; +use crate::ArgumentResolver; /// A collection of localization messages for a single locale, which are meant /// to be used together in a single view, widget or any other UI abstraction. @@ -488,6 +489,21 @@ impl FluentBundle { args: Option<&FluentArgs>, errors: &mut Vec, ) -> Cow<'bundle, str> + where + R: Borrow, + M: MemoizerKind, + { + self.format_pattern_with_argument_resolver(pattern, args, errors) + } + + /// Formats a pattern which comes from a `FluentMessage`. + /// Works the same as [`FluentBundle::format_pattern`], but allows passing a custom argument resolver. + pub fn format_pattern_with_argument_resolver<'bundle, 'args>( + &'bundle self, + pattern: &'bundle ast::Pattern<&'bundle str>, + args: impl ArgumentResolver<'args>, + errors: &mut Vec, + ) -> Cow<'bundle, str> where R: Borrow, M: MemoizerKind, diff --git a/fluent-bundle/src/lib.rs b/fluent-bundle/src/lib.rs index 4e180aec..83c0cc3b 100644 --- a/fluent-bundle/src/lib.rs +++ b/fluent-bundle/src/lib.rs @@ -122,6 +122,7 @@ pub use args::FluentArgs; pub type FluentBundle = bundle::FluentBundle; pub use errors::FluentError; pub use message::{FluentAttribute, FluentMessage}; +pub use resolver::ArgumentResolver; pub use resource::FluentResource; #[doc(inline)] pub use types::FluentValue; diff --git a/fluent-bundle/src/resolver/expression.rs b/fluent-bundle/src/resolver/expression.rs index ce030e4c..20772867 100644 --- a/fluent-bundle/src/resolver/expression.rs +++ b/fluent-bundle/src/resolver/expression.rs @@ -1,4 +1,4 @@ -use super::scope::Scope; +use super::scope::{ArgumentResolver, Scope}; use super::WriteValue; use std::borrow::Borrow; @@ -12,15 +12,16 @@ use crate::resource::FluentResource; use crate::types::FluentValue; impl<'bundle> WriteValue<'bundle> for ast::Expression<&'bundle str> { - fn write<'ast, 'args, 'errors, W, R, M>( + fn write<'ast, 'args, 'errors, W, R, M, Args>( &'ast self, w: &mut W, - scope: &mut Scope<'bundle, 'ast, 'args, 'errors, R, M>, + scope: &mut Scope<'bundle, 'ast, 'args, 'errors, R, M, Args>, ) -> fmt::Result where W: fmt::Write, R: Borrow, M: MemoizerKind, + Args: ArgumentResolver<'args>, { match self { Self::Inline(exp) => exp.write(w, scope), diff --git a/fluent-bundle/src/resolver/inline_expression.rs b/fluent-bundle/src/resolver/inline_expression.rs index 3f8c8d4f..27a660a8 100644 --- a/fluent-bundle/src/resolver/inline_expression.rs +++ b/fluent-bundle/src/resolver/inline_expression.rs @@ -1,4 +1,4 @@ -use super::scope::Scope; +use super::scope::{ArgumentResolver, Scope}; use super::{ResolveValue, ResolverError, WriteValue}; use std::borrow::Borrow; @@ -13,15 +13,16 @@ use crate::resource::FluentResource; use crate::types::FluentValue; impl<'bundle> WriteValue<'bundle> for ast::InlineExpression<&'bundle str> { - fn write<'ast, 'args, 'errors, W, R, M>( + fn write<'ast, 'args, 'errors, W, R, M, Args>( &'ast self, w: &mut W, - scope: &mut Scope<'bundle, 'ast, 'args, 'errors, R, M>, + scope: &mut Scope<'bundle, 'ast, 'args, 'errors, R, M, Args>, ) -> fmt::Result where W: fmt::Write, R: Borrow, M: MemoizerKind, + Args: ArgumentResolver<'args>, { match self { Self::StringLiteral { value } => unescape_unicode(w, value), @@ -100,9 +101,15 @@ impl<'bundle> WriteValue<'bundle> for ast::InlineExpression<&'bundle str> { } } Self::VariableReference { id } => { - let args = scope.local_args.as_ref().or(scope.args); + let resolved_arg; + let opt_arg = if let Some(args) = scope.local_args.as_ref() { + args.get(id.name) + } else { + resolved_arg = scope.args.resolve(id.name); + resolved_arg.as_ref().map(|it| it.as_ref()) + }; - if let Some(arg) = args.and_then(|args| args.get(id.name)) { + if let Some(arg) = opt_arg { arg.write(w, scope) } else { if scope.local_args.is_none() { @@ -148,13 +155,14 @@ impl<'bundle> WriteValue<'bundle> for ast::InlineExpression<&'bundle str> { } impl<'bundle> ResolveValue<'bundle> for ast::InlineExpression<&'bundle str> { - fn resolve<'ast, 'args, 'errors, R, M>( + fn resolve<'ast, 'args, 'errors, R, M, Args>( &'ast self, - scope: &mut Scope<'bundle, 'ast, 'args, 'errors, R, M>, + scope: &mut Scope<'bundle, 'ast, 'args, 'errors, R, M, Args>, ) -> FluentValue<'bundle> where R: Borrow, M: MemoizerKind, + Args: ArgumentResolver<'args>, { match self { Self::StringLiteral { value } => unescape_unicode_to_string(value).into(), @@ -164,8 +172,8 @@ impl<'bundle> ResolveValue<'bundle> for ast::InlineExpression<&'bundle str> { if let Some(arg) = local_args.get(id.name) { return arg.clone(); } - } else if let Some(arg) = scope.args.and_then(|args| args.get(id.name)) { - return arg.into_owned(); + } else if let Some(arg) = scope.args.resolve(id.name) { + return arg.as_ref().into_owned(); } if scope.local_args.is_none() { diff --git a/fluent-bundle/src/resolver/mod.rs b/fluent-bundle/src/resolver/mod.rs index cbab7e84..3cc73a23 100644 --- a/fluent-bundle/src/resolver/mod.rs +++ b/fluent-bundle/src/resolver/mod.rs @@ -10,7 +10,7 @@ mod pattern; mod scope; pub use errors::ResolverError; -pub use scope::Scope; +pub use scope::{ArgumentResolver, Scope}; use std::borrow::Borrow; use std::fmt; @@ -22,27 +22,29 @@ use crate::types::FluentValue; /// Resolves an AST node to a [`FluentValue`]. pub(crate) trait ResolveValue<'bundle> { /// Resolves an AST node to a [`FluentValue`]. - fn resolve<'ast, 'args, 'errors, R, M>( + fn resolve<'ast, 'args, 'errors, R, M, Args>( &'ast self, - scope: &mut Scope<'bundle, 'ast, 'args, 'errors, R, M>, + scope: &mut Scope<'bundle, 'ast, 'args, 'errors, R, M, Args>, ) -> FluentValue<'bundle> where R: Borrow, - M: MemoizerKind; + M: MemoizerKind, + Args: ArgumentResolver<'args>; } /// Resolves an AST node to a string that is written to source `W`. pub(crate) trait WriteValue<'bundle> { /// Resolves an AST node to a string that is written to source `W`. - fn write<'ast, 'args, 'errors, W, R, M>( + fn write<'ast, 'args, 'errors, W, R, M, Args>( &'ast self, w: &mut W, - scope: &mut Scope<'bundle, 'ast, 'args, 'errors, R, M>, + scope: &mut Scope<'bundle, 'ast, 'args, 'errors, R, M, Args>, ) -> fmt::Result where W: fmt::Write, R: Borrow, - M: MemoizerKind; + M: MemoizerKind, + Args: ArgumentResolver<'args>; /// Writes error information to `W`. This can be used to add FTL errors inline /// to a message. diff --git a/fluent-bundle/src/resolver/pattern.rs b/fluent-bundle/src/resolver/pattern.rs index e20bfcde..6bc704e6 100644 --- a/fluent-bundle/src/resolver/pattern.rs +++ b/fluent-bundle/src/resolver/pattern.rs @@ -1,4 +1,4 @@ -use super::scope::Scope; +use super::scope::{ArgumentResolver, Scope}; use super::{ResolverError, WriteValue}; use std::borrow::Borrow; @@ -14,15 +14,16 @@ use crate::types::FluentValue; const MAX_PLACEABLES: u8 = 100; impl<'bundle> WriteValue<'bundle> for ast::Pattern<&'bundle str> { - fn write<'ast, 'args, 'errors, W, R, M>( + fn write<'ast, 'args, 'errors, W, R, M, Args>( &'ast self, w: &mut W, - scope: &mut Scope<'bundle, 'ast, 'args, 'errors, R, M>, + scope: &mut Scope<'bundle, 'ast, 'args, 'errors, R, M, Args>, ) -> fmt::Result where W: fmt::Write, R: Borrow, M: MemoizerKind, + Args: ArgumentResolver<'args>, { let len = self.elements.len(); @@ -81,13 +82,14 @@ impl<'bundle> WriteValue<'bundle> for ast::Pattern<&'bundle str> { } impl<'bundle> ResolveValue<'bundle> for ast::Pattern<&'bundle str> { - fn resolve<'ast, 'args, 'errors, R, M>( + fn resolve<'ast, 'args, 'errors, R, M, Args>( &'ast self, - scope: &mut Scope<'bundle, 'ast, 'args, 'errors, R, M>, + scope: &mut Scope<'bundle, 'ast, 'args, 'errors, R, M, Args>, ) -> FluentValue<'bundle> where R: Borrow, M: MemoizerKind, + Args: ArgumentResolver<'args>, { let len = self.elements.len(); diff --git a/fluent-bundle/src/resolver/scope.rs b/fluent-bundle/src/resolver/scope.rs index 1ddff1a4..e110b810 100644 --- a/fluent-bundle/src/resolver/scope.rs +++ b/fluent-bundle/src/resolver/scope.rs @@ -4,15 +4,17 @@ use crate::resolver::{ResolveValue, ResolverError, WriteValue}; use crate::types::FluentValue; use crate::{FluentArgs, FluentError, FluentResource}; use fluent_syntax::ast; -use std::borrow::Borrow; +use std::borrow::{Borrow, Cow}; use std::fmt; +use std::marker::PhantomData; /// State for a single `ResolveValue::to_value` call. -pub struct Scope<'bundle, 'ast, 'args, 'errors, R, M> { +pub struct Scope<'bundle, 'ast, 'args, 'errors, R, M, Args> { + lt: PhantomData<&'args ()>, /// The current `FluentBundle` instance. pub bundle: &'bundle FluentBundle, /// The current arguments passed by the developer. - pub(super) args: Option<&'args FluentArgs<'args>>, + pub(super) args: Args, /// Local args pub(super) local_args: Option>, /// The running count of resolved placeables. Used to detect the Billion @@ -26,15 +28,16 @@ pub struct Scope<'bundle, 'ast, 'args, 'errors, R, M> { pub dirty: bool, } -impl<'bundle, 'ast, 'args, 'errors, R, M> Scope<'bundle, 'ast, 'args, 'errors, R, M> { +impl<'bundle, 'ast, 'args, 'errors, R, M, Args> Scope<'bundle, 'ast, 'args, 'errors, R, M, Args> { pub fn new( bundle: &'bundle FluentBundle, - args: Option<&'args FluentArgs>, + arg_resolver: Args, errors: Option<&'errors mut Vec>, ) -> Self { Scope { + lt: PhantomData, bundle, - args, + args: arg_resolver, local_args: None, placeables: 0, travelled: Default::default(), @@ -48,7 +51,11 @@ impl<'bundle, 'ast, 'args, 'errors, R, M> Scope<'bundle, 'ast, 'args, 'errors, R errors.push(error.into()); } } +} +impl<'bundle, 'ast, 'args, 'errors, R, M, Args: ArgumentResolver<'args>> + Scope<'bundle, 'ast, 'args, 'errors, R, M, Args> +{ /// This method allows us to lazily add Pattern on the stack, only if the /// `Pattern::resolve` has been called on an empty stack. /// @@ -138,3 +145,19 @@ impl<'bundle, 'ast, 'args, 'errors, R, M> Scope<'bundle, 'ast, 'args, 'errors, R } } } + +/// Determines how to retrieve argument values when resolving fluent messages. +/// This trait can be used to implement an alternative to [`FluentArgs`]. +/// +/// One example usage is for argument type safety that [`FluentArgs`] can't provide due to its +/// flexible nature. See `fluent-bundle/examples/typesafe_messages.rs` for an example of this. +pub trait ArgumentResolver<'a>: Copy { + fn resolve(self, name: &str) -> Option>>; +} + +impl<'args> ArgumentResolver<'args> for Option<&'args FluentArgs<'args>> { + fn resolve(self, name: &str) -> Option>> { + let arg = self?.get(name)?; + Some(Cow::Borrowed(arg)) + } +} diff --git a/fluent-bundle/src/types/mod.rs b/fluent-bundle/src/types/mod.rs index 24ec8c08..06e1fcb2 100644 --- a/fluent-bundle/src/types/mod.rs +++ b/fluent-bundle/src/types/mod.rs @@ -156,12 +156,13 @@ impl<'source> FluentValue<'source> { /// /// ``` /// use fluent_bundle::resolver::Scope; - /// use fluent_bundle::{types::FluentValue, FluentBundle, FluentResource}; + /// use fluent_bundle::{types::FluentValue, FluentArgs, FluentBundle, FluentResource}; /// use unic_langid::langid; /// /// let langid_ars = langid!("en"); /// let bundle: FluentBundle = FluentBundle::new(vec![langid_ars]); - /// let scope = Scope::new(&bundle, None, None); + /// let argument_resolver: Option<&FluentArgs> = None; + /// let scope = Scope::new(&bundle, argument_resolver, None); /// /// // Matching examples: /// assert!(FluentValue::try_number("2").matches(&FluentValue::try_number("2"), &scope)); @@ -176,10 +177,10 @@ impl<'source> FluentValue<'source> { /// assert!(!FluentValue::from("fluent").matches(&FluentValue::from("not fluent"), &scope)); /// assert!(!FluentValue::from("two").matches(&FluentValue::try_number("100"), &scope),); /// ``` - pub fn matches, M>( + pub fn matches, M, Args>( &self, other: &FluentValue, - scope: &Scope, + scope: &Scope, ) -> bool where M: MemoizerKind, @@ -213,7 +214,7 @@ impl<'source> FluentValue<'source> { } /// Write out a string version of the [`FluentValue`] to `W`. - pub fn write(&self, w: &mut W, scope: &Scope) -> fmt::Result + pub fn write(&self, w: &mut W, scope: &Scope) -> fmt::Result where W: fmt::Write, R: Borrow, @@ -237,7 +238,10 @@ impl<'source> FluentValue<'source> { /// /// Clones inner values when owned, borrowed data is not cloned. /// Prefer using [`FluentValue::into_string()`] when possible. - pub fn as_string, M>(&self, scope: &Scope) -> Cow<'source, str> + pub fn as_string, M, Args>( + &self, + scope: &Scope, + ) -> Cow<'source, str> where M: MemoizerKind, { @@ -259,7 +263,10 @@ impl<'source> FluentValue<'source> { /// /// Takes self by-value to be able to skip expensive clones. /// Prefer this method over [`FluentValue::as_string()`] when possible. - pub fn into_string, M>(self, scope: &Scope) -> Cow<'source, str> + pub fn into_string, M, Args>( + self, + scope: &Scope, + ) -> Cow<'source, str> where M: MemoizerKind, { diff --git a/fluent-bundle/tests/types_test.rs b/fluent-bundle/tests/types_test.rs index 08d4d9be..d778d073 100644 --- a/fluent-bundle/tests/types_test.rs +++ b/fluent-bundle/tests/types_test.rs @@ -21,7 +21,8 @@ fn fluent_value_matches() { // plural rules categories. let langid_ars = langid!("ars"); let bundle: FluentBundle = FluentBundle::new(vec![langid_ars]); - let scope = Scope::new(&bundle, None, None); + let arg_resolver: Option<&FluentArgs> = None; + let scope = Scope::new(&bundle, arg_resolver, None); let string_val = FluentValue::from("string1"); let string_val_copy = FluentValue::from("string1");