Add support for unnamed fields in `l10n_embed_derive`

finchie
Aug 13, 2025, 5:56 AM
R2BAN2V6VS4OBNG6MK5BOUYQCJMGLD37IIE5RX4HXZLQ3C5AQRHAC

Dependencies

  • [2] ARB66QTX Add support for unit structs/variants
  • [3] PGBXJWIH Move `l10n_embed` re-exports into `macro_prelude` module
  • [4] CESJ4CTO Move macro-specific code into `macro_impl` module
  • [5] ROSR4HD5 Parse captured glob as locale
  • [6] VQBJBFEX Improve error handling for missing Fluent messages
  • [7] RUCC2HKZ Rename from `fluent_embed` to `l10n_embed`
  • [8] 5FIVUZYF Unify `fluent_embed` macro API as `localize()`
  • [9] VNSHGQYN Support using glob paths in `localize` macro
  • [10] XGNME3WR Move `Group::derive_enum` to new `crate::parse_macro` module
  • [11] OCR4YRQ2 Parse group from fluent file specified by macro attribute
  • [12] NEBSVXIA Apply Clippy fixes
  • [13] QSK7JRBA Add simple `attribute_path` function
  • [14] NO3PDO7P Refactor `fluent_embed` to support structs
  • [15] 2XQ6ZB4W Store multiple locales in a single `Group`
  • [16] OWXLFLRM Merge `cli_macros` shim into `fluent_embed`
  • [17] QJC4IQIT Refactor `Localize` functions to infallibly return `String`
  • [18] QFPQZR4K Refactor `fluent_embed`
  • [19] LU6IFZFG Remove `std::io::Write` trait bound from `Localize`
  • [20] 6XEMHUGS Use full `Locale` instead of `LanguageIdentifier` subset
  • [21] XEEXWJLG Add simple end-to-end test for selectors
  • [22] JWZT34UC Add `Localize`` trait bound for each field in the derived item
  • [23] 7JPOCQEI Add explicit error handling for macro parsing
  • [24] 7U2DXFMP Refactor `fluent_embed::Localize` to support overriding locales
  • [25] YZ6PVVQC Add error handling for common unsupported Rust code
  • [26] F5LG7WEN Emit compilation errors from Fluent source code
  • [27] 3NMKD6I5 Refactor `Localize` trait to use `std::io::Write`
  • [28] RUFPE6OO Include canonical locale in list of available locales
  • [29] 5I5NR4DQ Make `Localize::CANONICAL_LOCALE` a function instead of associated constant

Change contents

  • file addition: unnamed_fields.rs (----------)
    [4.101]
    //! End-to-end test for unnamed field support in the `l10n_embed_derive` macro
    mod common;
    use icu_locale::{Locale, locale};
    use l10n_embed::Localize;
    use l10n_embed_derive::localize;
    const DEFAULT_LOCALE: Locale = locale!("en-US");
    const EXPECTED_LOCALES: [Locale; 4] = [
    locale!("zh-CN"),
    locale!("en-US"),
    locale!("ja-JP"),
    locale!("fr"),
    ];
    pub struct LocalizableType;
    impl Localize for LocalizableType {
    fn available_locales(&self) -> Vec<Locale> {
    EXPECTED_LOCALES.to_vec()
    }
    fn localize_for(&self, locale: &Locale) -> String {
    format!("Localized with locale: {locale}")
    }
    }
    #[localize("tests/locale/**/basic.ftl")]
    pub struct MessageStruct(LocalizableType);
    #[localize("tests/locale/**/basic.ftl")]
    pub enum MessageEnum {
    Greeting { name: String },
    Forward(LocalizableType),
    }
    mod forward {
    use super::common::compare_message;
    use super::{DEFAULT_LOCALE, EXPECTED_LOCALES, LocalizableType, MessageEnum, MessageStruct};
    use l10n_embed::Localize;
    use pretty_assertions::assert_eq;
    #[test]
    fn message() {
    let expected = "Localized with locale: en-US";
    compare_message(MessageStruct(LocalizableType), expected, DEFAULT_LOCALE);
    compare_message(
    MessageEnum::Forward(LocalizableType),
    expected,
    DEFAULT_LOCALE,
    );
    }
    #[test]
    fn available_locales() {
    let expected_locales = EXPECTED_LOCALES.to_vec();
    assert_eq!(
    expected_locales,
    MessageStruct(LocalizableType).available_locales()
    );
    assert_eq!(
    expected_locales,
    MessageEnum::Forward(LocalizableType).available_locales()
    );
    }
    }
    /// Ensure other enum variants are not affected
    mod preserves_enum_variants {
    use super::common::compare_message;
    use super::{DEFAULT_LOCALE, LocalizableType, MessageEnum};
    use l10n_embed::Localize;
    use pretty_assertions::{assert_eq, assert_ne};
    #[test]
    fn message() {
    compare_message(
    MessageEnum::Greeting {
    name: String::from("Ferris"),
    },
    "Hello, Ferris!",
    DEFAULT_LOCALE,
    );
    }
    #[test]
    fn available_locales() {
    let greeting = MessageEnum::Greeting {
    name: String::from("Ferris"),
    };
    assert_ne!(
    greeting.available_locales(),
    MessageEnum::Forward(LocalizableType).available_locales()
    );
    assert_eq!(vec![DEFAULT_LOCALE], greeting.available_locales());
    }
    }
  • edit in l10n_embed_derive/src/macro_impl/mod.rs at line 2
    [4.96]
    [4.96]
    use crate::macro_impl::derive::ReferenceKind;
  • replacement in l10n_embed_derive/src/macro_impl/mod.rs at line 14
    [4.24][4.0:29]()
    pub enum UnsupportedReason {
    [4.24]
    [4.29]
    pub enum UnsupportedError {
  • replacement in l10n_embed_derive/src/macro_impl/mod.rs at line 16
    [4.70][4.70:293]()
    Union,
    #[error("Unnamed fields are not supported")]
    UnnamedFields,
    }
    #[derive(Debug, Error)]
    #[error("Unsupported Rust code")]
    pub struct UnsupportedError {
    span: syn::Ident,
    reason: UnsupportedReason,
    [4.70]
    [4.293]
    Union { span: syn::Ident },
    #[error("Only 1 unnamed field is supported")]
    UnnamedFields {
    span: syn::Ident,
    field_count: usize,
    },
  • replacement in l10n_embed_derive/src/macro_impl/mod.rs at line 76
    [4.210][4.879:978](),[4.879][4.879:978]()
    syn::Data::Struct(_struct_data) => derive::locales_for_ident(&group, &derive_input.ident),
    [4.210]
    [4.978]
    syn::Data::Struct(struct_data) => derive::locales_for_ident(
    &group,
    &struct_data.fields,
    ReferenceKind::StructField,
    &derive_input.ident,
    ),
  • replacement in l10n_embed_derive/src/macro_impl/mod.rs at line 84
    [4.398][4.398:464]()
    return Err(MacroError::Unsupported(UnsupportedError {
    [4.398]
    [4.464]
    return Err(MacroError::Unsupported(UnsupportedError::Union {
  • edit in l10n_embed_derive/src/macro_impl/mod.rs at line 86
    [4.514][4.514:564]()
    reason: UnsupportedReason::Union,
  • replacement in l10n_embed_derive/src/macro_impl/mod.rs at line 96
    [4.624][4.624:690]()
    return Err(MacroError::Unsupported(UnsupportedError {
    [4.624]
    [4.690]
    return Err(MacroError::Unsupported(UnsupportedError::Union {
  • edit in l10n_embed_derive/src/macro_impl/mod.rs at line 98
    [4.740][4.740:790]()
    reason: UnsupportedReason::Union,
  • replacement in l10n_embed_derive/src/macro_impl/mod.rs at line 109
    [4.1700][4.1700:1976](),[4.1976][2.0:100](),[2.100][4.836:1022](),[4.836][4.836:1022](),[4.1022][4.1119:1140](),[4.1140][4.1042:1056](),[4.1042][4.1042:1056](),[4.1056][4.2002:2013](),[4.2002][4.2002:2013]()
    let named_fields: Vec<&syn::Type> = match &derive_input.data {
    syn::Data::Struct(struct_data) => match &struct_data.fields {
    syn::Fields::Named(named_fields) => {
    named_fields.named.iter().map(|field| &field.ty).collect()
    }
    syn::Fields::Unit => Vec::new(),
    syn::Fields::Unnamed(_unnamed_fields) => {
    return Err(MacroError::Unsupported(UnsupportedError {
    span: derive_input.ident.clone(),
    reason: UnsupportedReason::UnnamedFields,
    }));
    }
    },
    [4.1700]
    [4.2013]
    let field_types: Vec<&syn::Type> = match &derive_input.data {
    syn::Data::Struct(struct_data) => {
    types_for_fields(&struct_data.fields, derive_input.ident.clone())?
    }
  • replacement in l10n_embed_derive/src/macro_impl/mod.rs at line 116
    [4.2103][4.1057:1108](),[4.1108][4.2159:2213](),[4.2159][4.2159:2213](),[4.2213][4.1109:1192](),[4.1192][4.2282:2300](),[4.2282][4.2282:2300](),[4.2300][2.101:441](),[2.441][4.2330:2345](),[4.1393][4.2330:2345](),[4.2330][4.2330:2345](),[4.2345][4.1394:1453]()
    .map(|variant| match &variant.fields {
    syn::Fields::Named(named_fields) => {
    Ok(named_fields.named.iter().map(|field| &field.ty).collect())
    }
    syn::Fields::Unit => Ok(Vec::new()),
    syn::Fields::Unnamed(_unnamed_fields) => {
    Err(MacroError::Unsupported(UnsupportedError {
    span: variant.ident.clone(),
    reason: UnsupportedReason::UnnamedFields,
    }))
    }
    })
    .collect::<Result<Vec<Vec<&syn::Type>>, _>>()?
    [4.2103]
    [4.1453]
    .map(|variant| types_for_fields(&variant.fields, variant.ident.clone()))
    .collect::<Result<Vec<Vec<&syn::Type>>, UnsupportedError>>()?
  • replacement in l10n_embed_derive/src/macro_impl/mod.rs at line 121
    [4.2369][4.2369:2419]()
    syn::Data::Union(_union_data) => todo!(),
    [4.2369]
    [4.2419]
    syn::Data::Union(_union_data) => {
    return Err(MacroError::Unsupported(UnsupportedError::Union {
    span: derive_input.ident.clone(),
    }));
    }
  • replacement in l10n_embed_derive/src/macro_impl/mod.rs at line 130
    [4.2536][4.2536:2745](),[4.2745][4.991:1063](),[4.178][4.2822:2834](),[4.1063][4.2822:2834](),[4.2822][4.2822:2834]()
    let additional_bounds = named_fields
    .into_iter()
    .map(|field| -> syn::WherePredicate {
    // Attribute this bound to the original source code
    let span = field.span();
    parse_quote_spanned!(span=> #field: ::l10n_embed::Localize)
    });
    [4.2536]
    [4.2834]
    let additional_bounds = field_types.into_iter().map(|field| -> syn::WherePredicate {
    // Attribute this bound to the original source code
    let span = field.span();
    parse_quote_spanned!(span=> #field: ::l10n_embed::Localize)
    });
  • edit in l10n_embed_derive/src/macro_impl/mod.rs at line 168
    [4.2182]
    fn types_for_fields(
    fields: &syn::Fields,
    span: syn::Ident,
    ) -> Result<Vec<&syn::Type>, UnsupportedError> {
    match fields {
    syn::Fields::Named(named_fields) => {
    Ok(named_fields.named.iter().map(|field| &field.ty).collect())
    }
    syn::Fields::Unit => Ok(Vec::new()),
    syn::Fields::Unnamed(unnamed_fields) => {
    let unnamed_field_types: Vec<&syn::Type> = unnamed_fields
    .unnamed
    .iter()
    .map(|field| &field.ty)
    .collect();
    match unnamed_field_types.len() {
    1 => Ok(unnamed_field_types),
    _ => Err(UnsupportedError::UnnamedFields {
    span,
    field_count: unnamed_field_types.len(),
    }),
    }
    }
    }
    }
  • replacement in l10n_embed_derive/src/macro_impl/error.rs at line 2
    [4.1515][4.681:757]()
    MacroError, ParseError, UnsupportedError, UnsupportedReason, attribute,
    [4.1515]
    [4.1530]
    MacroError, ParseError, UnsupportedError, attribute,
  • replacement in l10n_embed_derive/src/macro_impl/error.rs at line 107
    [4.1723][4.1723:1838]()
    match error.reason {
    UnsupportedReason::Union => emit_error! { error.span, "unions are not supported";
    [4.1723]
    [4.1838]
    match error {
    UnsupportedError::Union { span } => emit_error! { span, "unions are not supported";
  • replacement in l10n_embed_derive/src/macro_impl/error.rs at line 111
    [4.1903][4.1903:2111](),[4.2111][4.801:898]()
    UnsupportedReason::UnnamedFields => {
    emit_error! { error.span, "only named fields are supported";
    help = "Each field needs a name so it can be referenced by Fluent code";
    note = "There must be at least one named field (unit structs are unsupported!)";
    [4.1903]
    [4.898]
    UnsupportedError::UnnamedFields { span, field_count } => {
    emit_error! { span, "only 1 unnamed field is supported, got {} fields", field_count;
    help = "When there are multiple fields, each field needs a name so it can be referenced by Fluent code";
    note = "Using a single unnamed field forwards to that field's implementation of `Localize`";
  • replacement in l10n_embed_derive/src/macro_impl/derive.rs at line 9
    [4.2296][4.2296:2358]()
    use super::{MacroError, UnsupportedError, UnsupportedReason};
    [4.2296]
    [4.9191]
    use super::{MacroError, UnsupportedError};
  • replacement in l10n_embed_derive/src/macro_impl/derive.rs at line 26
    [4.1003][4.3156:3186](),[4.178][4.3156:3186]()
    reference_kind: &Context,
    [4.1003]
    [4.1004]
    context: &Context,
  • replacement in l10n_embed_derive/src/macro_impl/derive.rs at line 28
    [4.1051][4.1051:1135]()
    let canonical_message = group.remove_canonical_message(ident, reference_kind)?;
    [4.1051]
    [4.397]
    let canonical_message = group.remove_canonical_message(ident, context)?;
  • replacement in l10n_embed_derive/src/macro_impl/derive.rs at line 31
    [4.403][4.1136:1196]()
    .remove_additional_messages(ident, reference_kind)?
    [4.403]
    [4.453]
    .remove_additional_messages(ident, context)?
  • edit in l10n_embed_derive/src/macro_impl/derive.rs at line 68
    [4.521]
    [4.521]
    fn expr_for_unnamed_fields(
    unnamed_fields: &syn::FieldsUnnamed,
    ident: &syn::Ident,
    context: &Context,
    ) -> Result<TokenStream, UnsupportedError> {
    let field_count = unnamed_fields.unnamed.iter().count();
    if field_count != 1 {
    return Err(UnsupportedError::UnnamedFields {
    span: ident.clone(),
    field_count,
    });
    }
    let field_reference = match context.reference_kind {
    ReferenceKind::EnumField => quote!(unnamed_field),
    ReferenceKind::StructField => quote!(self.0),
    };
    Ok(quote!(#field_reference.localize_for(locale)))
    }
  • replacement in l10n_embed_derive/src/macro_impl/derive.rs at line 105
    [4.45][4.1348:1481]()
    pub fn locales_for_ident(group: &fluent::Group, ident: &syn::Ident) -> TokenStream {
    let id = ident.to_string().to_kebab_case();
    [4.45]
    [4.1481]
    pub fn locales_for_ident(
    group: &fluent::Group,
    fields: &syn::Fields,
    reference_kind: ReferenceKind,
    ident: &syn::Ident,
    ) -> TokenStream {
    match fields {
    // Provide available locales based on what Fluent source files are available
    syn::Fields::Named(_) | syn::Fields::Unit => {
    let id = ident.to_string().to_kebab_case();
  • replacement in l10n_embed_derive/src/macro_impl/derive.rs at line 116
    [4.1482][4.1482:1689]()
    let locale_literals = group
    .locales_for_message(&id)
    .map(|locale| locale.id.to_string())
    .map(|locale_string| syn::LitStr::new(&locale_string, proc_macro2::Span::call_site()));
    [4.1482]
    [4.1689]
    let locale_literals = group
    .locales_for_message(&id)
    .map(|locale| locale.id.to_string())
    .map(|locale_string| {
    syn::LitStr::new(&locale_string, proc_macro2::Span::call_site())
    });
    quote!(
    vec![self.canonical_locale(), #(::l10n_embed::macro_prelude::icu_locale::locale!(#locale_literals)),*]
    )
    }
    // Forward to the unnamed field's implementation
    syn::Fields::Unnamed(_unnamed_fields) => {
    let unnamed_field_ident = match reference_kind {
    ReferenceKind::EnumField => quote!(unnamed_field),
    ReferenceKind::StructField => quote!(self.0),
    };
  • replacement in l10n_embed_derive/src/macro_impl/derive.rs at line 134
    [4.1690][4.1690:1776](),[4.1776][3.843:972]()
    // There is only one message for this struct, so just list every supported locale
    quote!(
    vec![self.canonical_locale(), #(::l10n_embed::macro_prelude::icu_locale::locale!(#locale_literals)),*]
    )
    [4.1690]
    [4.1841]
    quote!(#unnamed_field_ident.available_locales())
    }
    }
  • replacement in l10n_embed_derive/src/macro_impl/derive.rs at line 145
    [4.1200][4.1200:1236](),[4.1236][4.3187:3241](),[4.3241][4.1293:1348](),[4.9823][4.1293:1348](),[4.1293][4.1293:1348](),[4.1348][4.9824:9880](),[4.9880][4.1394:1468](),[4.1394][4.1394:1468](),[4.1468][4.9881:9946](),[4.9946][4.1527:1538](),[4.1527][4.1527:1538](),[4.1538][2.442:481]()
    let references = match fields {
    syn::Fields::Named(named_fields) => Context {
    // Reference using `self.{reference_name}`
    reference_kind: ReferenceKind::StructField,
    // Create a list of unique field names that can be referenced
    valid_references: unique_named_fields(named_fields),
    },
    syn::Fields::Unit => Context {
    [4.1200]
    [2.481]
    let context = match fields {
    syn::Fields::Named(named_fields) => {
    Context {
    // Reference using `self.{reference_name}`
    reference_kind: ReferenceKind::StructField,
    // Create a list of unique field names that can be referenced
    valid_references: unique_named_fields(named_fields),
    }
    }
    syn::Fields::Unit | syn::Fields::Unnamed(_) => Context {
  • edit in l10n_embed_derive/src/macro_impl/derive.rs at line 158
    [2.594][2.594:645](),[2.645][4.2414:2575](),[4.2414][4.2414:2575](),[4.2575][4.1290:1307](),[4.1307][4.2591:2601](),[4.2591][4.2591:2601]()
    syn::Fields::Unnamed(_unnamed_fields) => {
    return Err(MacroError::Unsupported(UnsupportedError {
    span: ident.clone(),
    reason: UnsupportedReason::UnnamedFields,
    }));
    }
  • replacement in l10n_embed_derive/src/macro_impl/derive.rs at line 160
    [4.1628][4.2602:2660]()
    Ok(expr_for_message(&mut group, ident, &references)?)
    [4.1628]
    [4.1589]
    Ok(match fields {
    syn::Fields::Named(_) | syn::Fields::Unit => expr_for_message(&mut group, ident, &context)?,
    syn::Fields::Unnamed(unnamed_fields) => {
    expr_for_unnamed_fields(unnamed_fields, ident, &context)?
    }
    })
  • replacement in l10n_embed_derive/src/macro_impl/derive.rs at line 177
    [4.2183][4.2183:2630]()
    // Simplify match code by always ignoring enum fields (even if they don't exist)
    // We are matching the variant name, not any data, so each arm will have something like:
    // Self::VariantName { .. }
    // Even if `Self::VariantName` doesn't contain any data
    let locales_for_variant = locales_for_ident(group, variant_ident);
    match_arms.push(quote!(Self::#variant_ident { .. } => #locales_for_variant));
    [4.2183]
    [4.2630]
    let destructuring_pattern = match &enum_variant.fields {
    // Simplify match code by always ignoring enum fields (even if they don't exist)
    // We are matching the variant name, not any data, so each arm will have something like:
    // Self::VariantName { .. }
    // Even if `Self::VariantName` doesn't contain any data
    syn::Fields::Named(_) | syn::Fields::Unit => quote!(Self::#variant_ident { .. }),
    // Bind the single unnamed field so we can use its implementation of `Localize::available_locales()`
    syn::Fields::Unnamed(_unnamed_fields) => quote!(Self::#variant_ident(unnamed_field)),
    };
    let locales_for_variant = locales_for_ident(
    group,
    &enum_variant.fields,
    ReferenceKind::EnumField,
    variant_ident,
    );
    match_arms.push(quote!(#destructuring_pattern => #locales_for_variant));
  • replacement in l10n_embed_derive/src/macro_impl/derive.rs at line 223
    [2.709][2.709:764](),[2.764][4.2720:2906](),[4.2720][4.2720:2906](),[4.2906][4.1308:1329]()
    syn::Fields::Unnamed(_unnamed_fields) => {
    return Err(MacroError::Unsupported(UnsupportedError {
    span: enum_variant.ident.clone(),
    reason: UnsupportedReason::UnnamedFields,
    }));
    [2.709]
    [4.2926]
    syn::Fields::Unnamed(unnamed_fields) => {
    let field_count = unnamed_fields.unnamed.iter().count();
    if field_count != 1 {
    return Err(MacroError::Unsupported(UnsupportedError::UnnamedFields {
    span: enum_variant.ident.clone(),
    field_count,
    }));
    }
    quote!(#variant_pascal_case(unnamed_field))
  • replacement in l10n_embed_derive/src/macro_impl/derive.rs at line 236
    [4.83][4.1732:1786]()
    let references = match &enum_variant.fields {
    [4.83]
    [4.3242]
    let context = match &enum_variant.fields {
  • replacement in l10n_embed_derive/src/macro_impl/derive.rs at line 241
    [4.1973][2.765:808]()
    syn::Fields::Unit => Context {
    [4.1973]
    [2.808]
    syn::Fields::Unit | syn::Fields::Unnamed(_) => Context {
  • replacement in l10n_embed_derive/src/macro_impl/derive.rs at line 245
    [2.931][2.931:986](),[2.986][4.2960:3146](),[4.2960][4.2960:3146](),[4.3146][4.1330:1351]()
    syn::Fields::Unnamed(_unnamed_fields) => {
    return Err(MacroError::Unsupported(UnsupportedError {
    span: enum_variant.ident.clone(),
    reason: UnsupportedReason::UnnamedFields,
    }));
    [2.931]
    [4.3166]
    };
    let arm_body = match &enum_variant.fields {
    syn::Fields::Named(_) | syn::Fields::Unit => {
    expr_for_message(&mut group, &enum_variant.ident, &context)?
    }
    syn::Fields::Unnamed(unnamed_fields) => {
    expr_for_unnamed_fields(unnamed_fields, &enum_variant.ident, &context)?
  • edit in l10n_embed_derive/src/macro_impl/derive.rs at line 255
    [4.2074][4.2074:2075](),[4.2075][4.1347:1435]()
    let arm_body = expr_for_message(&mut group, &enum_variant.ident, &references)?;