Keyboard shortcuts

Press or to navigate between chapters

Press ? to show this help

Press Esc to hide this help

Deriving messages

The EsFluent derive macro turns a struct or enum into a localizable message. Each type maps to one or more keys in your .ftl files, and fields become Fluent arguments.

  • Enums: Each variant becomes a message ID (e.g., MyEnum::Variantmy_enum-Variant).
  • Structs: The struct itself becomes the message ID (e.g., MyStructmy_struct).
  • Fields: Fields are automatically exposed as arguments to the Fluent message.
use es_fluent::EsFluent;

#[derive(EsFluent)]
pub enum LoginError {
    InvalidPassword,                   // no params
    UserNotFound { username: String }, // exposed as $username in the ftl file
    Something(String, String, String), // exposed as $f0, $f1, $f2 in the ftl file
    SomethingArgNamed(
        #[fluent(arg = "input")] String,
        #[fluent(arg = "expected")] String,
        #[fluent(arg = "details")] String,
    ), // exposed as $input, $expected, $details
}

#[derive(EsFluent)]
pub struct WelcomeMessage<'a> {
    pub name: &'a str, // exposed as $name in the ftl file
    pub count: i32,    // exposed as $count in the ftl file
}

The CLI generates the following FTL entries for these types:

## LoginError

login_error-InvalidPassword = Invalid Password
login_error-Something = Something { $f0 } { $f1 } { $f2 }
login_error-SomethingArgNamed = Something Arg Named { $input } { $expected } { $details }
login_error-UserNotFound = User Not Found { $username }

## WelcomeMessage

welcome_message = Welcome Message { $name } { $count }

At runtime, call i18n.localize_message(&value) on an explicit manager to resolve translations:

let _ = i18n.localize_message(&LoginError::InvalidPassword);
let _ = i18n.localize_message(&LoginError::UserNotFound { username: "john".to_string() });
let _ = i18n.localize_message(&LoginError::Something("a".to_string(), "b".to_string(), "c".to_string()));
let _ = i18n.localize_message(&LoginError::SomethingArgNamed("a".to_string(), "b".to_string(), "c".to_string()));

let welcome = WelcomeMessage { name: "John", count: 5 };
let _ = i18n.localize_message(&welcome);

Field arguments:

  • arg = "..." on a field renames that exposed Fluent argument (works on struct fields, enum named fields, and enum tuple fields).
  • #[fluent(skip)] on a field excludes that field from generated arguments.
  • #[fluent(value = |x: &String| x.len())] transforms a field before inserting it as a Fluent argument.
  • Plain Option<T> fields are inferred as optional Fluent arguments. Some inserts the converted value; None still inserts the argument as FluentValue::None.
  • #[fluent(selector)] on Option<T> fields creates an optional selector argument.
  • #[fluent(selector)] and #[fluent(value = ...)] are mutually exclusive on the same field. Explicit value attributes override Option<T> inference.

Message IDs and resources:

  • #[fluent(key = "...")] on an enum variant overrides that variant’s key suffix. On unit-only EsFluent enums, it also overrides the inferred selector value.
  • #[fluent(skip)] and #[fluent(key = "...")] cannot be combined on the same enum variant.
  • #[fluent(id = "...")] on an enum overrides the generated base key. Reserve it for a fixed external FTL contract; the generated name is normally clearer.
  • #[fluent(domain = "...")] on an enum or struct routes generated FTL to an additional package-local domain declared in i18n.toml.
  • Generated FTL IDs must be unique within one package-local domain, including across its namespace files. The same ID may be reused in another domain or package.
  • For namespaced types, check validates the expected namespace file; a key in {crate}.ftl still counts as missing if the Rust type belongs in {crate}/{namespace}.ftl.

Generated variants:

#[fluent_variants(skip)] omits a struct field or enum variant from generated variant enums; keys = [...] values must be lowercase snake_case.

Choose the missing-message policy

A configured crate uses the strict package-local policy by default and validates every derived message and label against a resolvable value in its fallback locale during compilation. Add es-fluent-build and call track_i18n_assets() from Cargo’s selected custom-build target; see Incremental builds. Missing keys and messages that have attributes but no value fail at the declaring Rust item with the domain, fallback root, and recovery command. Missing build-helper wiring receives a separate setup diagnostic that points to cargo es-fluent doctor.

Set fallback-str in the owning package’s i18n.toml when rendering should continue if the active locale, Fluent locale fallback, and the configured fallback resource cannot resolve a value:

missing_message_policy = "fallback-str"

Strict and fallback-string packages can coexist in one workspace build.

Normal localize_message(...) and localize_label(...) calls then return these snake_case values:

Derived outputFallback sourceExample
EsFluent structStruct nameWelcomeMessagewelcome_message
EsFluent enum messageVariant nameInvalidPasswordinvalid_password
EsFluentVariants messageSource field or variant namedisplay_namedisplay_name
EsFluentLabelLabeled type nameLoginFormlogin_form

The concrete embedded, Dioxus, and Bevy managers all use this shared behavior. try_localize_message(...) and try_localize_label(...) remain fallible and return None instead of applying the string fallback. The policy does not permit malformed FTL or duplicate message/term IDs.

Localized temporal arguments

Enable the feature for the date/time library used by your message fields:

[dependencies]
es-fluent = { version = "0.18", features = ["icu-datetime"] }

Temporal fields work like other derived arguments, including borrowed fields, Option<T>, and values returned by #[fluent(value = ...)]:

use es_fluent::EsFluent;
use std::time::{Duration, SystemTime};

#[derive(EsFluent)]
pub struct EventStartsAt {
    pub starts_at: SystemTime,
}

#[derive(EsFluent)]
pub struct OperationElapsed {
    pub elapsed: Duration,
}

The generated argument can be interpolated directly in FTL:

event_starts_at = Starts { $starts_at }
operation_elapsed = Completed in { $elapsed }
FeatureSupported field types
icu-datetimestd::time::SystemTime, std::time::Duration, plus ICU4X Date<Gregorian>, Time, DateTime<Gregorian>, and ZonedDateTime<Gregorian, TimeZoneInfo<AtTime>>
chronoNaiveDate, NaiveTime, NaiveDateTime, and DateTime<Tz> for any Tz: TimeZone
jiffcivil::Date, civil::Time, civil::DateTime, Timestamp, Zoned, Span, and SignedDuration

Calendar, time, instant, and zoned values use ICU4X’s medium localized formats for the manager’s active Fluent locale. Zoned values include a localized short UTC offset. SystemTime is treated as a UTC instant on either side of the Unix epoch and converted with millisecond precision. Duration is balanced through hours, minutes, seconds, and subsecond units, then rendered with ICU4X’s locale-aware short duration format. Jiff Timestamp values are rendered in UTC. Jiff Span and SignedDuration arguments use Jiff’s friendly duration format.

Delegate skipped wrapper variants

#[fluent(skip)] on a single-field enum variant suppresses that variant’s own key and delegates context-bound rendering to the wrapped value. This is useful for transparent wrapper enums.

use es_fluent::EsFluent;

#[derive(EsFluent)]
pub enum NetworkError {
    ApiUnavailable,
}

#[derive(EsFluent)]
pub enum TransactionError {
    #[fluent(skip)]
    Network(NetworkError),
}

let _ = i18n.localize_message(&TransactionError::Network(NetworkError::ApiUnavailable));
## NetworkError

network_error-ApiUnavailable = API is unavailable

Use choices

Choices allow an enum to be used inside another message as a Fluent selector (e.g., for gender or category). Unit-only enums that derive EsFluent infer EsFluentChoice automatically. Variants serialize as kebab-case by default, so GenderChoice::Male becomes male and a compound variant like VeryFriendly becomes very-friendly. Derived choice values are emitted as validated StaticFluentVariantKey values. Use #[fluent_choice(rename_all = "...")] on the same enum to change selector casing. Styles that generate invalid selector values, such as values containing spaces, are rejected at compile time. Use standalone #[derive(EsFluentChoice)] only for selector enums that should not also be registered as messages.

use es_fluent::EsFluent;

#[derive(EsFluent)]
pub enum GenderChoice {
    Male,
    Female,
    Other,
}

#[derive(EsFluent)]
pub struct Greeting<'a> {
    pub name: &'a str,
    #[fluent(selector)] // Matches $gender -> [male]...
    pub gender: Option<&'a GenderChoice>,
}

In the FTL file, the selector field can drive a selector:

greeting = { $gender ->
    [male] Welcome Mr. { $name }
    [female] Welcome Ms. { $name }
   *[other] Welcome { $name }
}
let greeting = Greeting { name: "John", gender: Some(&GenderChoice::Male) };
let _ = i18n.localize_message(&greeting);

Generate variants

EsFluentVariants generates key-value pair enums for struct fields or enum variants. This is useful for generating UI labels, placeholders, or descriptions for a form object, and it can also expose enum variants as localizable keys.

use es_fluent::{EsFluent, EsFluentVariants};

#[derive(EsFluentVariants)]
#[fluent_variants(keys = ["label", "description"])]
pub struct LoginFormVariants {
    pub username: String,
    pub password: String,
}

#[derive(EsFluent)]
pub struct ActiveFormField {
    #[fluent(selector)]
    pub field: LoginFormVariantsLabelVariants,
}

This generates two enums with corresponding FTL entries:

## LoginFormVariantsLabelVariants

login_form_variants_label_variants-password = Password
login_form_variants_label_variants-username = Username

## LoginFormVariantsDescriptionVariants

login_form_variants_description_variants-password = Password
login_form_variants_description_variants-username = Username
let _ = i18n.localize_message(&LoginFormVariantsLabelVariants::Username);
let _ = i18n.localize_message(&ActiveFormField {
    field: LoginFormVariantsLabelVariants::Username,
});

Generated variant enums also implement EsFluentChoice, so they can drive selector fields:

active_form_field =
    { $field ->
        [username] Editing username
       *[password] Editing password
    }

Enums are supported too. In that case, the derive generates a single ...Variants enum over the original variants:

use es_fluent::EsFluentVariants;

#[derive(EsFluentVariants)]
pub enum SettingsTab {
    General,
    Notifications,
    Privacy,
}
## SettingsTabVariants

settings_tab_variants-General = General
settings_tab_variants-Notifications = Notifications
settings_tab_variants-Privacy = Privacy
let _ = i18n.localize_message(&SettingsTabVariants::Notifications);

keys = [...] values must be lowercase snake_case. Use #[fluent_variants(skip)] to omit a struct field or enum variant from the generated enums. Generated enums derive Clone, Copy, Debug, Eq, Hash, and PartialEq automatically and implement EsFluentChoice, so they can be used directly in #[fluent(selector)] fields. Use derive(...) inside #[fluent_variants(...)] for additional traits; EsFluentChoice is already inferred.

Type-level labels

EsFluentLabel generates a FluentLabel implementation that registers the type’s name as a key. Where EsFluentVariants registers individual fields, EsFluentLabel registers the parent type itself.

Type label

#[derive(EsFluentLabel)] creates a single key for the type. The derive is enough to register the type label; no additional #[fluent_label(...)] flag is needed for the parent type itself.

use es_fluent::EsFluentLabel;

#[derive(EsFluentLabel)]
pub enum GenderLabelOnly {
    Male,
    Female,
    Other,
}
gender_label_only_label = Gender Label Only
use es_fluent::FluentLabel;
let _ = GenderLabelOnly::localize_label(&i18n);
let _ = GenderLabelOnly::try_localize_label(&i18n);
let _ = GenderLabelOnly::fluent_label_key();

Combine labels with generated variants

#[derive(EsFluentVariants)] also gives each generated variant enum a type-level label key inferred from the generated enum name:

use es_fluent::{EsFluentLabel, EsFluentVariants};

#[derive(EsFluentLabel, EsFluentVariants)]
#[fluent_variants(keys = ["label", "description"])]
pub struct LoginFormCombined {
    pub username: String,
    pub password: String,
}
login_form_combined_label_variants_label = Login Form Combined Label Variants
login_form_combined_description_variants_label = Login Form Combined Description Variants
use es_fluent::FluentLabel;
let _ = LoginFormCombinedDescriptionVariants::localize_label(&i18n);