kellpossible / cargo-i18n

A Rust Cargo sub-command and libraries to extract and build localization resources to embed in your application/library
MIT License
120 stars 24 forks source link

No access to Fluent attributes through the fl! macro and LanguageLoader #96

Closed Almost-Senseless-Coder closed 1 year ago

Almost-Senseless-Coder commented 1 year ago

Fluent offers a powerful means of grouping related translations: attributes. They can get added to a message to indicate that they belong to that message somehow, even if they do not show up in the message text. Example:

dialog-text = Please select the file to upload
    .aria-label = File upload dialog
    .call-to-action = Choose a file...
    .selected-file =
        { $filename ->
            [""] No file selected
            *[other] $filename
        }

The fluent-bundle crate gives access to these attributes using the FluentMessage::get_attribute() method and one can format an attribute using FluentBundle::format_pattern().

Currently i18n-embed doesn't offer access to this functionality, which is, in my opinion, a serious limitation. I worked around this using the following code:


/// Returns the specified attribute for the specified message, with no arguments.
/// 
/// Implementing this here is necessary since the i18n-embed crate
/// does not offer a way to access attributes as of yet.
pub fn get_attr(state: LanguageLoaderState, message: &str, attr: &str) -> Result<String, Vec<FluentError>> {
    let args: HashMap<Cow<'_, str>, FluentValue<'_>> = HashMap::new();
    get_attr_with_args(state, message, attr, args)
}

/// Returns the specified attribute for a given message, formatted with the provided arguments.
///
/// Implementing this here is necessary since the i18n-embed crate
/// does not offer a way to access attributes as of yet.
pub fn get_attr_with_args<'args, K, V>(
    state: LanguageLoaderState,
    message: &str,
    attr: &str,
    args: HashMap<K, V>,
) -> Result<String, Vec<FluentError>>
where
    K: Into<Cow<'args, str>>,
    V: Into<FluentValue<'args>>,
{
    let args = if args.is_empty() {
        None
    } else {
        Some(args.into_iter().collect::<FluentArgs>())
    };
    state
        .language_loader
        .with_fluent_message(message, |message| {
            let val = message
                .get_attribute(attr)
                .expect("Requested invalid attribute - this is a bug.")
                .value();

            let current_language = state.current_language.clone();
            let mut bundle = FluentBundle::new(vec![current_language]);
            let (path, file) = state
                .language_loader
                .language_file(&state.current_language, &Localizations);
            let file = file.unwrap();
            let file_string = String::from_utf8(file.to_vec())
                .expect("Turning the language file to a UTF-8 String failed - this is a bug.")
                .replace("\u{000D}\n", "\n");
            let res = FluentResource::try_new(file_string)
                .expect("Fluent failed to parse the text in the language file.");
            bundle
                .add_resource(res)
                .expect("Failed to add the `FluentResource` to the `FluentBundle`.");
            let mut errors = Vec::new();
            let str = bundle
                .format_pattern(val, args.as_ref(), &mut errors)
                .to_string();
            if errors.is_empty() {
                Ok(str)
            } else {
                Err(errors)
            }
        })
        .expect_throw("No message found - this is a bug.")
}

( Note: The LanguageLoaderState is just an application-specific wrapper type that enables global shared state on my application.)

I will attempt implementing

PR incoming.

m00nwtchr commented 1 year ago

You don't need to create a new FluentResource and FluentBundle for this, you can access the existing Bundles /w FluentLanguageLoader#with_bundles_mut. But yes, a proper way to do this would be nice to have.

Almost-Senseless-Coder commented 1 year ago

@m00nwtchr Doesn't suffice, since you cannot return anything worthwhile from with_bundles_mut, i.e. you can gain access to the attribute, but not return it to your application.

Unlike with_message, which returns an Option of whatever the closure returns, with_bundles_mut returns ().

m00nwtchr commented 1 year ago

You can, this is the solution that I've been using in my app:

pub fn fl(message_id: &str, attribute: Option<&str>) -> Option<String> {
    let mut message = OnceCell::new();
    LANGUAGE_LOADER.with_bundles_mut(|bundle| {
        if message.get().is_none() {
            if let Some(msg) = bundle.get_message(message_id) {
                if let Some(pattern) = if let Some(attribute) = attribute {
                    msg.get_attribute(attribute).map(|v| v.value())
                } else {
                    msg.value()
                } {
                    message.set(bundle.format_pattern(pattern, None, &mut vec![]).to_string()).unwrap();
                }
            }
        }
    });
    message.take()
}
kellpossible commented 1 year ago

Thanks @m00nwtchr for the suggested workaround.

Fixed in #98