projectfluent / fluent-rs

Rust implementation of Project Fluent
https://projectfluent.org
Apache License 2.0
1.09k stars 98 forks source link

Fluent × SNAFU collaboration #107

Open shepmaster opened 5 years ago

shepmaster commented 5 years ago

Howdy! I'm the author of SNAFU an error type library. I think it would be very powerful to be able to use Fluent to enhance error types with localized error messages.

An error type enhanced with SNAFU looks something like this:

#[derive(Debug, Snafu)]
enum OneError {
    #[snafu(display("Something bad happened with the value {}", value))]
    Something { value: i32 },

    #[snafu(display("Another bad thing happened to user {}: {}", username, source))]
    Another { source: AnotherError, username: String },
}

This implements all the appropriate error traits, but Display is hard-coded to whatever the programmer typed.

In my head, I'm wondering if the two crates could be combined to create something used like this:

#[derive(Debug, Snafu)]
enum OneError {
    #[snafu(display("Something bad happened with the value {}", value))]
    #[fluent("one-error-something")]
    Something { value: i32 },

    #[snafu(display("Another bad thing happened to user {}: {}", username, source))]
    #[fluent("one-error-another")]
    Another { source: AnotherError, username: String },
}

struct FluentError<E> {
    db: FluentData, // A reference, Rc, Arc; whatever
    err: E,
}

use std::fmt;

impl fmt::Display for FluentError<E>
where
    E: IntoFluent + Error,
{
    fn fmt(&self, f: &mut Formatter) -> fmt::Result {
        match self.db.format(self.e) {
            Ok(s) => s.fmt(f),
            Err(_) => self.e.fmt(f),
        }
    }
}

Highlights:

  1. A fluent procedural macro identifies the key of the error and implements a new IntoFluent trait.
  2. The trait returns the HashMap of properties and the key.
  3. A new type wraps any error type that also implements IntoFluent, using the translation available from fluent or falling back to the built-in Display implementation.

For Fluent's side, I think that everything I just described should be agnostic of the error library. I believe that SNAFU would just be a great fit for making use of it!

zbraniecki commented 5 years ago

Yeah, that sounds like a great use of Fluent! For lower-level command-line with no events and no runtime-locale-switches, all you should need is a macro on top of fluent-bundle, right?

XAMPPRocky commented 4 years ago

@shepmaster You might be interested in my fluent-templates library. It provides a pretty high level API that should make this kind of integration easy.

alerque commented 4 years ago

Somewhat related, see discussion of Fluent for localization of Clap: https://github.com/clap-rs/clap/issues/380#issuecomment-622557010

alerque commented 6 months ago

@shepmaster Are you still interested in this? We're in a position to actually facilitate contributions moving forward in this project now.

I also know there has been some progress in building localization into Clap and an fl! macro out there that has some overlap with what you're looking for.

shepmaster commented 6 months ago

I am indeed still interested!

Since my original post, the syntax of SNAFU hasn't drastically changed, so an error still is defined something like:

use snafu::prelude::*;

#[derive(Debug, Snafu)]
#[snafu(display("The example failed for user {name}"))]
struct ExampleError {
    source: std::io::Error,
    name: String,
}

fn demo() -> Result<(), ExampleError> {
    std::fs::read_to_string("/etc/hosts").context(ExampleSnafu { name: "viv" })?;

    Ok(())
}

Among other things, this expands into a Display implementation somewhat like:

impl fmt::Display for ExampleError {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        match self {
            Self { name, source } => {
                write!(f, "The example failed for user {name}")
            }
        }
    }
}

I guess my biggest mental block is how to connect a user's language preference to the Display implementation. Two big problems come to mind:

  1. Display doesn't take any arguments that would allow passing in the preference, which indicates that the preference would need to be some kind of global variable (yuck).
  2. The last time I checked, various Fluent operations are fallible and Display implementations are not allowed to create failures.

This seems to indicate that Display shouldn't be used for this purpose, so some other method would probably be preferred.

Unfortunately, we can only downcast an error to a concrete type, not to a trait, so it's not possible to create a sibling trait (e.g. FluentDisplay) and try to cast to that.

It may be possible to use generic member access somehow here, but I'm not quite sure how.


Beyond that, I don't have experience with the mechanics of defining Fluent translations and how they get into the binary, so it's possible that some amount of coordination would need to be done by the SNAFU macro.

alerque commented 6 months ago

Initial thoughts re...

  1. The most obvious place this info comes from in most applications is environment variables. That doesn't play super nice with point 2, but those are the beans...

  2. Obviously the main reason Fluent operations are setup to be fallible is that they rely on some combination of external data (typically localization files) plus arguments that typically come from user data. That's user input coming at us from two directions, so parse failures and invalid/mismatched input args are just something we have to deal with.

    That being said this is not the first time or context that compile-time validated infallible messages have come up. I ran across #253 today, but I know there have been others with more discussion somewhere.

    c.f. https://github.com/projectfluent/fluent-rs/issues/36#issuecomment-339287112

shepmaster commented 6 months ago

I played around with this a bit, and I think we can have something reasonably nice if Fluent would consider adding a few traits and types and a nightly unstable feature were to stabilize. Here's a code dump with some explanatory comments:

#![feature(error_generic_member_access)]

use fluent::{FluentArgs, FluentBundle, FluentError, FluentResource};
use snafu::prelude::*;
use std::{borrow::Borrow, error, fmt};
use unic_langid::langid;

// Ideally, we could reduce the user's API surface area down to one
// attribute that defines what the message ID would be. Attributes may
// also need to be added to individual fields to indicate which fields
// should be available to Fluent.

// #[snafu(fluent("hello-world"))]
// struct ExampleError {
//     source: std::io::Error,
//     #[snafu(fluent)]
//     name: String,
// }

#[derive(Debug, Snafu)]
#[snafu(display("The example failed for user {name}"))]
// This uses the generic member access feature to return a trait
// object. It's explicit here, but would by implied by the theoretical
// `snafu(fluent)` attribute above.
#[snafu(provide(ref, dyn FluentDisplay => self))]
struct ExampleError {
    source: std::io::Error,
    name: String,
}

// This code would be generated by the SNAFU macros
impl FluentDisplay for ExampleError {
    fn fmt(&self, f: &mut dyn FluentFormatter) -> Result<(), FormatterError> {
        f.write_message("hello-world", {
            let mut args = FluentArgs::new();
            args.set("name", &self.name);
            args
        })
    }
}

// These traits are the trickiest part...
//
// - In order to be able to create a `dyn FluentDisplay`, the trait
// must have no generics. However, `FluentBundle` is a generic type.
//
// - The decision of which bundle to use should come from outside of
// the thing being formatted.
//
// Those constraints lead to this dual trait solution.
//
// For maximum usefulness, these traits (or something similar) should
// be a part of Fluent. If they were a part of SNAFU, then the
// ecosystem at large couldn't benefit from them and there would be
// greatly decreased interoperability.
trait FluentDisplay {
    fn fmt(&self, f: &mut dyn FluentFormatter) -> Result<(), FormatterError>;
}

trait FluentFormatter {
    fn write_message(&mut self, id: &str, args: FluentArgs) -> Result<(), FormatterError>;
}

// I used SNAFU to define this, but that couldn't happen for real as
// it would result in circular dependencies :-)
#[derive(Debug, Snafu)]
#[snafu(module)]
enum FormatterError {
    #[snafu(display("Fluent message `{id}` does not exist"))]
    MessageDoesNotExist { id: String },

    #[snafu(display("Fluent message `{id}` has no value"))]
    MessageHasNoValue { id: String },

    #[snafu(display("Could not format the Fluent message"))]
    Formatting { source: fmt::Error },

    #[snafu(display("Internal Fluent errors occurred"))]
    Fluent { errors: Vec<FluentError> },
}

// This is a demonstration implementation of `FluentFormatter` and
// this implementation (or something similar) should probably live
// alongside the trait definition.
struct Formatter<'a, R, W> {
    bundle: &'a FluentBundle<R>,
    output: W,
}

impl<'a, R, W> Formatter<'a, R, W>
where
    R: Borrow<FluentResource>,
    W: fmt::Write,
{
    fn new(bundle: &'a FluentBundle<R>, output: W) -> Self {
        Self { bundle, output }
    }

    fn into_inner(self) -> W {
        self.output
    }
}

impl<R, W> FluentFormatter for Formatter<'_, R, W>
where
    R: Borrow<FluentResource>,
    W: fmt::Write,
{
    fn write_message(&mut self, id: &str, args: FluentArgs) -> Result<(), FormatterError> {
        use formatter_error::*;

        let msg = self
            .bundle
            .get_message(id)
            .context(MessageDoesNotExistSnafu { id })?;

        let pattern = msg
            .value() // TODO: PR about this as the docs are wrong.
            .context(MessageHasNoValueSnafu { id })?;

        let args = Some(&args);

        let mut errors = vec![];

        self.bundle
            .write_pattern(&mut self.output, pattern, args, &mut errors)
            .context(FormattingSnafu)?;

        ensure!(errors.is_empty(), FluentSnafu { errors });

        Ok(())
    }
}

// An example that fails
fn demo() -> Result<(), ExampleError> {
    std::fs::read_to_string("/no/no/no").context(ExampleSnafu { name: "viv" })?;

    Ok(())
}

fn main() {
    // Set up our dummy bundle
    let bundle = {
        let ftl_string = "hello-world = The example failed for user { $name }".to_owned();
        let res = FluentResource::try_new(ftl_string).expect("Failed to parse an FTL string.");
        let langid_en = langid!("en-US");
        let mut bundle = FluentBundle::new(vec![langid_en]);

        bundle
            .add_resource(res)
            .expect("Failed to add FTL resources to the bundle.");

        bundle
    };

    let error = demo().unwrap_err();

    let as_display = error::request_ref::<dyn FluentDisplay>(&error).unwrap();
    let mut m = Formatter::new(&bundle, String::new());
    as_display.fmt(&mut m).unwrap();
    let value = m.into_inner();
    assert!(value.contains("The example failed"), "was: {value}");

    println!(
        "{}",
        FluentReport {
            bundle: &bundle,
            error: &error
        }
    );
}

// SNAFU would probably add some functionality akin to this to format
// error messages nicely for end users.
struct FluentReport<'a, R> {
    bundle: &'a FluentBundle<R>,
    error: &'a (dyn error::Error + 'static),
}

impl<R> fmt::Display for FluentReport<'_, R>
where
    R: Borrow<FluentResource>,
{
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        let mut head = Some(self.error);
        let mut idx = 0;

        while let Some(error) = head {
            head = error.source();
            idx += 1;

            write!(f, "{idx}: ")?;

            match error::request_ref::<dyn FluentDisplay>(error) {
                Some(fluent_error) => {
                    let mut fluent_f = Formatter::new(self.bundle, &mut *f);
                    if let Err(e) = fluent_error.fmt(&mut fluent_f) {
                        fmt::Display::fmt(error, f)?;
                        fmt::Display::fmt(&e, f)?;
                    }
                }

                None => {
                    fmt::Display::fmt(error, f)?;
                }
            }

            writeln!(f)?;
        }

        Ok(())
    }
}

TL;DR, what's your gut reaction on adding the FluentFormatter and FluentDisplay traits?

alerque commented 6 months ago

I haven't had a chance to run or fiddle with your code dumps yet, but my gut reaction is quite positive. I don't think this runs afoul of anything we need to watch out for: not breaking the existing API surface area if there isn't a very compelling reason to do so, not regressing any performance, not forcefully exposing folks to dependencies they may not like, etc. If we need to we can start with additional traits behind a feature flag, especially for anything that needs nightly. We definitely need to go easy or our MSRV.

On the flip side of watching out for anything that regresses existing use cases, making things more ergonomic and enabling new use cases like this is definitely a plus.

shepmaster commented 6 months ago

especially for anything than needs nightly

The good news is that the nightly code would be constrained to SNAFU — Fluent should just need to gain some new traits and types. I haven't tested, but I don't see anything obvious about the traits that would cause them to require an extremely modern version of Rust.

Would you like me to assemble a PR adding these traits and types to have a place for detailed discussion?

alerque commented 6 months ago

Yes please!

shepmaster commented 6 months ago

Which crate would you expect FluentDisplay / FluentFormatter / etc. to live in? The docs for fluent state

At the moment it is expected that users will use the fluent_bundle crate directly, while the ecosystem matures and higher level APIs are being developed.

However, these traits feels like they could be one of those higher level APIs.

I'll start by adding it to fluent directly, but I'm happy to put it wherever seems more appropriate.

shepmaster commented 6 months ago

I've opened #361

alerque commented 6 months ago

Sorry I'm a bit behind here. My kids and I got sick and nothing is getting done right :-(

I don't have a good feel for where these traits should go. I've been rather baffled by the fluent vs fluent-bundle crate split myself, quite a few aspects of the split feel counter intuative to me. I suspect there was an intention to head a direction that didn't end up materializing at all and the split is somewhat arbitrary at this point. I don't plan to rock the boat and merge them or anything soon, but I also don't have a clear vision for why some things would go one place and some another. If anything with fluent-bundle being more widely used directly by other higher level crates it would seem more useful to be to put traits like this is there. That being said your gut level instincts for this are much more likely to be useful than mine.

alerque commented 6 months ago

Given my own lack of clarity on the scope of each crate, I took a stab in #359 and redoing the summaries of each one. The docs have been terrible on this point sometimes having specific descriptions, sometimes just parroting the main project talking points. This is particularly unhelpful on crates.io when searching for crates because several different crates that have identical summaries is useless, and the readmes just having boiler plate doesn't help understand the specific crate you are looking at.

I don't know if my understanding is right, but any feedback on whether the wording is helpful to clarify what might be found where would be appreciated.