L10n library - API question

Hi all!

I'm one of the authors of the Fluent Localization System and I lead the effort to port it to Rust.

It's still pretty early and so far we're working on the low-level portion of the API, which we hope to eventually wrap in friendly macros.

I'd like to get your opinion on what type of API would you prefer/expect when requesting a localizable value out of a localization "bundle".

The value may be there or not, and if it's there, it may be formatted with or without errors.

Our design principles guide us to always try to return some value, even if we encounter errors.

It means that if you're trying to format a message that references a variable, and we couldn't find or format the variable, we'd like to return partially formatted message because we believe it to be a better user experience over breaking the app.

So, if you have a message like this: hello-user = Hello, { $userName } and we couldn't resolve $userName, we'd like to return Hello $userName and a list of errors we encountered.

To achieve it we see two possible API paths:

Path1

fn format(id: &str) -> Option<Result<String, (String, Vec<Error>)>>{};

Path 2

fn format(id: &str) -> Option<(String, Vec<Error>)>{};

Path1 seems to be more explicit, but also a bit more hassle since you'll have to handle the error scenario coalescing it to the Ok() scenario and collect errors.

Path2 seems less Rusty, but has the benefit of "You know .0 will be the thing to display, and .1 may have errors".

Any advice on what's a better path to take in Rust?

2 Likes

I'm kinda new to Rust, so this may not be relevant.

I think you could use custom error types for this, something like this.

I my opinion this looks cleaner, because the user would know he is using a partial result, he can also choose to throw an error for some reason.

If you always want to return some value, why is your return type Option in both proposals? What does None value mean?

3 Likes

How about:

fn format( id: &str ) -> FormattingResult

where FormattingResult is:

struct FormattingResult {
   formatted_value : String;
   errors : Option<Vec<Error>>;
}

This clearly shows that you will always get some formatting result and an optional list of Errors that you may or may not be interested in doing anything with.

2 Likes
-> (String, Vec<Warning>)

looks fine to me (if it's not fatal, I'd call it Warning, not Error though).

I'm not sure about Option. If id may be missing, I'd expect:

-> Result<(String, Vec<Warning>), IdMissingError>

How do users use the errors? Do they affect program flow locally, or are they just FYI for the developer? If the latter, I'd decouple them from the format:

let getter = L10n::with_error_callback(|e| eprintln!("{}", e));
let msg = getter.format("msg");

And it's perfectly fine to have multiple versions of the function, e.g. format(), try_format().

1 Like

Returning errors/warnings in a vector won't result on a check for the length of the vector on every call of format?

For example if I have the message welcome = Welcome { $user } and $user is missing, but when it fails I want to replace the $user with "stranger" or display another message.

If you return

-> (String, Vec<Warning>)

You'll have to check for the length of the vector every time, while with a custom error type you'll be able to do it with a match.

Or maybe I'm getting the wrong idea about the format function.

The standard library has a good example of a custom error type that exposes partial results here: Utf8Error in std::str - Rust

Hi all! Thanks for your responses!

We treat a scenario of missing message differently from partially resolved message.

If you ask for hello-world, out of a given bundle, and the message is present in it, we will try to format it and we assume even half-formatted message like this is the optimal value to present to the user in the UI.
If the message is completely missing, we'll try a fallback locale (that will happen in a higher-level API), so it's useful for us to recognize that in this bundle/locale this message is not present.
Hence Option<>

You would never do this. Part of the l10n paradigm is that you never manipulate the return value - it's opaque.

If you (as a developer) wanted to perform such action you'd verify that you have the $user before you call the L10n API and replace it with stranger before you pass the argument to the l10n's format function.

That's a good point. I guess that's why I think I didn't like the second option :slight_smile:

CC @stas

Missing ID will likely affect the higher level API to attempt to retrieve the message from a fallback bundle (fallback locale perhaps). Value formatted with errors will rather stay and the errors will be displayed to the developer in some debug mode.

The decision on how to treat and what fallback strategy to use is left to a higher level API that will be written on top of this.

I think I like this approach if there's was no performance cost of always constructing the Vec :confused:

In the optimistic scenario, we'll be returning 100+ messages with no errors in them in production and it will be on a startup path. Creating 100+ empty Vecs seems like a higher cost than returning 100+ Results that are either Ok or Err with Vec. Am I wrong here?

0-lenght Vec is free. It only allocates when you insert something.

I love rust.

How about an enum { Ok(String), Partial (String, Vec), IdMissing } ? I think Rust likes explicitness, and you have to somehow match on the return value to filter the IdMissing case anyway.

2 Likes

Thanks everyone! I went with a modified proposal from @kornel for now and just released fluent-rs 0.4.1 - https://crates.io/crates/fluent :slight_smile:
Still WIP, but starts taking shape :slight_smile: