Add support for integer selectors

finchie
Aug 18, 2025, 6:40 AM
XDJBTEXUZNIAC2TKC4Z3OZORWAXR4ZWYUHNM6OEGXTL6WZDXOVZQC

Dependencies

  • [2] FF67HCOF Improve Fluent syntax error spans
  • [3] EKXWNEPK Rename `Localize::message_for_locale` to `Localize::localize_for`
  • [4] PGBXJWIH Move `l10n_embed` re-exports into `macro_prelude` module
  • [5] MVTRHSJL Move from `fluent-syntax` PR to main branch
  • [6] F64TRIFZ Test both structs and enums in `l10n_embed_derive`
  • [7] HHJDRLLN Create `fluent_embed_runtime` crate
  • [8] KZLFC7OW Rename `fluent_embed_runtime` to `fluent_embed`
  • [9] 6XEMHUGS Use full `Locale` instead of `LanguageIdentifier` subset
  • [10] MABGENI7 Refactor `fluent_embed_derive` tests
  • [11] 6ABVDTXZ Improve `fluent_embed_derive` test suite
  • [12] 3NMKD6I5 Refactor `Localize` trait to use `std::io::Write`
  • [13] C6W7N6N5 Implement `Localize` for `FixedDecimal` and primitive number types
  • [14] 7X4MEZJU Use Fluent AST when reporting error spans
  • [15] 2SITVDYW Handle common errors in Fluent code
  • [16] 7FYXVNAB Ignore comments in Fluent source code
  • [17] 7M4UI3TW Update dependencies to latest versions
  • [18] Y6YSEDJM Fix bug preventing structs from using selectors
  • [19] F5LG7WEN Emit compilation errors from Fluent source code
  • [20] VNSHGQYN Support using glob paths in `localize` macro
  • [21] CESJ4CTO Move macro-specific code into `macro_impl` module
  • [22] EAPOUW73 Fix compilation error with `fluent-syntax` span PR
  • [23] QFPQZR4K Refactor `fluent_embed`
  • [24] 5TEX4MNU Split `fluent_embed` into `group` and `parse` modules
  • [25] S2444K42 Refactor selectors test to not rely on funciton calls
  • [26] XEEXWJLG Add simple end-to-end test for selectors
  • [27] RUCC2HKZ Rename from `fluent_embed` to `l10n_embed`
  • [28] AAERM7PB Add selector tests for the `fr` locale

Change contents

  • replacement in l10n_embed_derive/tests/selectors.rs at line 12
    [7.23][7.396:431](),[7.114][7.396:431](),[7.396][7.396:431]()
    Emails { unread_emails: u64 },
    [7.23]
    [7.24]
    Numbers { unread_emails: u64 },
    Plurals { unread_emails: u64 },
    }
    #[localize("tests/locale/**/selectors.ftl")]
    pub struct Numbers {
    unread_emails: u64,
  • replacement in l10n_embed_derive/tests/selectors.rs at line 22
    [7.72][7.72:92]()
    pub struct Emails {
    [7.72]
    [7.92]
    pub struct Plurals {
  • edit in l10n_embed_derive/tests/selectors.rs at line 28
    [7.65]
    [7.35]
    #[case::zero_en(locale!("en-US"), 0, "You have no unread emails.")]
    #[case::one_en(locale!("en-US"), 1, "You have an unread email.")]
    #[case::two_en(locale!("en-US"), 2, "You have 2 unread emails.")]
    #[case::max_en(
    locale!("en-US"),
    u64::MAX,
    "You have 18,446,744,073,709,551,615 unread emails."
    )]
    #[case::zero_fr(locale!("fr"), 0, "Vous n'avez aucun e-mail non lu.")]
    #[case::one_fr(locale!("fr"), 1, "Vous avez un e-mail non lu.")]
    #[case::two_fr(locale!("fr"), 2, "Vous avez 2 e-mails non lus.")]
    #[case::max_fr(locale!("fr"), u64::MAX, "Vous avez 18 446 744 073 709 551 615 e-mails non lus.")]
    fn numbers(#[case] locale: Locale, #[case] unread_emails: u64, #[case] expected_message: String) {
    compare_message(Numbers { unread_emails }, &expected_message, locale.clone());
    compare_message(
    MessageEnum::Numbers { unread_emails },
    expected_message,
    locale,
    );
    }
    /// End-to-end test of locale-specific selectors implementation by checking final output
    #[rstest]
  • replacement in l10n_embed_derive/tests/selectors.rs at line 63
    [7.562][3.603:620](),[3.620][7.563:591](),[7.1090][7.563:591](),[7.591][7.702:772](),[7.702][7.702:772](),[7.772][7.1186:1190](),[7.1186][7.1186:1190](),[7.1190][6.0:82]()
    fn localize_for(
    #[case] locale: Locale,
    #[case] unread_emails: u64,
    #[case] expected_message: String,
    ) {
    compare_message(Emails { unread_emails }, &expected_message, locale.clone());
    [7.562]
    [6.82]
    fn plurals(#[case] locale: Locale, #[case] unread_emails: u64, #[case] expected_message: String) {
    compare_message(Plurals { unread_emails }, &expected_message, locale.clone());
  • replacement in l10n_embed_derive/tests/selectors.rs at line 66
    [6.103][6.103:150]()
    MessageEnum::Emails { unread_emails },
    [6.103]
    [6.150]
    MessageEnum::Plurals { unread_emails },
  • replacement in l10n_embed_derive/tests/locale/fr/selectors.ftl at line 1
    [7.1578][7.1579:1588]()
    emails =
    [7.1578]
    [7.1588]
    # Selectors example from Fluent guide: https://projectfluent.org/fluent/guide/selectors.html
    # Poorly translated into French :)
    numbers =
    { $unreadEmails ->
    [0] Vous n'avez aucun e-mail non lu.
    [1] Vous avez un e-mail non lu.
    *[other] Vous avez { $unreadEmails } e-mails non lus.
    }
    # French singular includes both 0 and 1
    plurals =
  • replacement in l10n_embed_derive/tests/locale/en-US/selectors.ftl at line 3
    [7.276][7.1153:1162](),[7.1152][7.1153:1162]()
    emails =
    [7.276]
    [7.1162]
    numbers =
    { $unreadEmails ->
    [0] You have no unread emails.
    [1] You have an unread email.
    *[other] You have { $unreadEmails } unread emails.
    }
    plurals =
  • edit in l10n_embed_derive/src/fluent/mod.rs at line 3
    [7.1058]
    [7.1082]
    use std::num::ParseIntError;
  • edit in l10n_embed_derive/src/fluent/mod.rs at line 47
    [5.336]
    [5.336]
    // span: SourceSpan,
    },
    #[error("invalid number literal")]
    InvalidNumberLiteral {
    invalid_literal: String,
    parse_error: ParseIntError,
    #[source_code]
    source_code: NamedSource<String>,
    // TODO: blocked on https://github.com/projectfluent/fluent-rs/pull/373
    // #[label("This can't be parsed as an unsigned 128-bit integer")]
  • edit in l10n_embed_derive/src/fluent/mod.rs at line 59
    [7.1364]
    [7.1364]
    #[error("invalid identifier type")]
    InvalidIdentifierType {
    invalid_identifier: String,
    expected_type: ast::VariantType,
    found_type: ast::VariantType,
    #[source_code]
    source_code: NamedSource<String>,
    // TODO: blocked on https://github.com/projectfluent/fluent-rs/pull/373
    // #[label("This can't be parsed as an unsigned 128-bit integer")]
    // span: SourceSpan,
    },
  • replacement in l10n_embed_derive/src/fluent/ast.rs at line 7
    [7.807][7.5591:5675](),[7.1537][7.5591:5675](),[7.2217][7.5591:5675]()
    use fluent_syntax::ast::{Expression, InlineExpression, PatternElement, VariantKey};
    [7.807]
    [7.2329]
    use fluent_syntax::ast::{
    Expression, InlineExpression, Pattern, PatternElement, Variant, VariantKey,
    };
  • replacement in l10n_embed_derive/src/fluent/ast.rs at line 11
    [7.2437][7.182:216](),[7.5700][7.182:216](),[7.182][7.182:216]()
    use quote::{format_ident, quote};
    [7.2386]
    [7.216]
    use proc_macro2::{Literal, TokenStream};
    use quote::{ToTokens, format_ident, quote};
  • edit in l10n_embed_derive/src/fluent/ast.rs at line 14
    [7.238]
    [7.238]
    #[derive(PartialEq, Eq, Clone, Copy, Debug)]
    pub enum VariantType {
    Integer,
    Plural,
    }
    struct Select<'variants> {
    variant_type: VariantType,
    default_pattern: TokenStream,
    default_expression: &'variants Pattern<String>,
    match_arms: Vec<(TokenStream, &'variants Pattern<String>)>,
    }
  • replacement in l10n_embed_derive/src/fluent/ast.rs at line 40
    [5.639][7.1293:1528](),[7.1088][7.1293:1528](),[7.3286][7.1293:1528]()
    let match_target = inline_expression(selector, message_context)?;
    let default_arm = OnceCell::new();
    let mut additional_arms = Vec::with_capacity(variants.len());
    [5.639]
    [7.6888]
    let select = select(&message_context, variants)?;
  • replacement in l10n_embed_derive/src/fluent/ast.rs at line 42
    [7.6889][7.6889:6939](),[7.6939][7.1529:1610](),[7.1610][5.640:709](),[7.78][7.1611:1755](),[5.709][7.1611:1755](),[7.7086][7.1611:1755](),[7.1755][4.1064:1191](),[7.371][7.7266:7408](),[4.1191][7.7266:7408](),[7.1728][7.7266:7408](),[7.1869][7.7266:7408](),[7.2276][7.7266:7408](),[7.7266][7.7266:7408](),[7.7408][7.1870:1954](),[7.1954][7.7486:7691](),[7.7486][7.7486:7691](),[7.7691][7.1955:2145](),[7.7763][7.2146:2422](),[7.2422][7.8138:8175](),[7.8138][7.8138:8175](),[7.8175][7.2423:2520]()
    for variant in variants {
    let variant_pattern: syn::Pat = match &variant.key {
    VariantKey::Identifier { name } => {
    let ident_pascal_case =
    format_ident!("{}", name.to_pascal_case());
    parse_quote!(::l10n_embed::macro_prelude::icu_plurals::PluralCategory::#ident_pascal_case)
    }
    VariantKey::NumberLiteral { .. } => todo!(),
    };
    // Create a new `MessageContext` for each variant body
    let variant_context = MessageContext {
    pattern: &variant.value,
    ..message_context
    };
    let variant_body = message_body(variant_context)?;
    // The default pattern must go last so we don't generate invalid match stataments
    if variant.default {
    default_arm
    .set(quote!(#variant_pattern | _ => #variant_body))
    .expect("Multiple default patterns for match statement.");
    } else {
    additional_arms.push(quote!(#variant_pattern => #variant_body));
    [7.6889]
    [7.2520]
    let raw_match_target = inline_expression(selector, message_context)?;
    let match_target = match select.variant_type {
    VariantType::Integer { .. } => quote!(#raw_match_target),
    VariantType::Plural { .. } => {
    // Only dereference if enum variant
    let category_reference =
    match message_context.derive_context.reference_kind {
    ReferenceKind::EnumField => quote!(*#raw_match_target),
    ReferenceKind::StructField => quote!(#raw_match_target),
    };
    quote!(plural_rules.category_for(#category_reference))
  • replacement in l10n_embed_derive/src/fluent/ast.rs at line 54
    [7.2550][7.2550:2576]()
    }
    [7.2550]
    [7.3624]
    };
  • replacement in l10n_embed_derive/src/fluent/ast.rs at line 56
    [7.3625][2.83:165](),[2.165][7.2634:2704](),[7.2634][7.2634:2704]()
    // The parser should guarantee a default arm is available
    let default_arm = default_arm.get().unwrap();
    [7.3625]
    [7.8350]
    let default_match_pattern = select.default_pattern;
    let default_match_expression = message_body(MessageContext {
    pattern: select.default_expression,
    ..message_context
    })?;
  • replacement in l10n_embed_derive/src/fluent/ast.rs at line 62
    [7.8351][7.360:711]()
    // Only dereference if enum variant
    let category_reference = match message_context.derive_context.reference_kind {
    ReferenceKind::EnumField => quote!(*#match_target),
    ReferenceKind::StructField => quote!(#match_target),
    };
    [7.8351]
    [7.711]
    let match_patterns = select
    .match_arms
    .iter()
    .map(|(pattern, _expression)| pattern)
    .collect::<Vec<&TokenStream>>();
    let match_expressions = select
    .match_arms
    .iter()
    .map(|(_match_pattern, expression)| {
    message_body(MessageContext {
    pattern: expression,
    ..message_context
    })
    })
    .collect::<Result<Vec<syn::Expr>, Error>>()?;
  • replacement in l10n_embed_derive/src/fluent/ast.rs at line 79
    [7.3664][7.713:796](),[7.796][7.2782:2882](),[7.1616][7.2782:2882](),[7.2782][7.2782:2882]()
    match plural_rules.category_for(#category_reference) {
    #(#additional_arms,)*
    #default_arm,
    [7.3664]
    [7.3778]
    match #match_target {
    #(#match_patterns => #match_expressions),*
    #default_match_pattern => #default_match_expression,
  • edit in l10n_embed_derive/src/fluent/ast.rs at line 101
    [7.1629]
    [7.4128]
    fn select<'variants>(
    message_context: &MessageContext,
    variants: &'variants Vec<Variant<String>>,
    ) -> Result<Select<'variants>, Error> {
    let expected_variant_type = OnceCell::new();
    for variant in variants {
    let variant_type = match &variant.key {
    VariantKey::Identifier { name } => match name.as_str() {
    // "other" is ambiguous as it could be used for plurals or as the default integer case
    "other" => None,
    _ => Some(VariantType::Plural),
    },
    VariantKey::NumberLiteral { .. } => Some(VariantType::Integer),
    };
    // Make sure all variants are of the same type
    if let Some(variant_kind) = variant_type {
    let expected_type = expected_variant_type.get_or_init(|| variant_kind);
    if *expected_type != variant_kind {
    todo!()
    }
    }
    }
    match expected_variant_type.get().unwrap() {
    VariantType::Integer => select_integers(message_context, variants),
    VariantType::Plural => select_plurals(message_context, variants),
    }
    }
    fn select_integers<'variants>(
    message_context: &MessageContext,
    variants: &'variants Vec<Variant<String>>,
    ) -> Result<Select<'variants>, Error> {
    let mut default_expression = OnceCell::new();
    let mut match_arms = Vec::new();
    for variant in variants {
    match &variant.key {
    VariantKey::Identifier { name } => match name.to_lowercase().as_str() {
    "other" => default_expression
    .set(&variant.value)
    .expect("Multiple default variants"),
    _ => {
    return Err(Error::InvalidIdentifierType {
    invalid_identifier: name.clone(),
    expected_type: VariantType::Integer,
    found_type: VariantType::Plural,
    source_code: message_context.source.named_source.clone(),
    });
    }
    },
    VariantKey::NumberLiteral { value } => {
    let parsed_integer: u128 = match value.parse() {
    Ok(integer) => integer,
    Err(parse_error) => {
    return Err(Error::InvalidNumberLiteral {
    invalid_literal: value.clone(),
    parse_error,
    source_code: message_context.source.named_source.clone(),
    });
    }
    };
    let integer_literal = Literal::u128_unsuffixed(parsed_integer);
    match_arms.push((integer_literal.to_token_stream(), &variant.value));
    }
    }
    }
    let default_expression = default_expression.take().expect("No default variant");
    Ok(Select {
    variant_type: VariantType::Integer,
    default_pattern: quote!(_),
    default_expression,
    match_arms,
    })
    }
    fn select_plurals<'variants>(
    message_context: &MessageContext,
    variants: &'variants Vec<Variant<String>>,
    ) -> Result<Select<'variants>, Error> {
    let mut default_arm = OnceCell::new();
    let mut match_arms = Vec::new();
    for variant in variants {
    match &variant.key {
    VariantKey::Identifier { name } => {
    let name_pascal_case = name.to_pascal_case();
    let ident = syn::Ident::new(&name_pascal_case, proc_macro2::Span::call_site());
    let pattern =
    quote!(::l10n_embed::macro_prelude::icu_plurals::PluralCategory::#ident);
    match variant.default {
    // The default arm should include any enum variants not matched
    true => default_arm
    .set((quote!(#pattern | _), &variant.value))
    .expect("Multiple default variants"),
    false => match_arms.push((pattern, &variant.value)),
    }
    }
    VariantKey::NumberLiteral { value } => {
    return Err(Error::InvalidIdentifierType {
    invalid_identifier: value.clone(),
    expected_type: VariantType::Plural,
    found_type: VariantType::Integer,
    source_code: message_context.source.named_source.clone(),
    });
    }
    }
    }
    let (default_pattern, default_expression) = default_arm.take().expect("No default variant");
    Ok(Select {
    variant_type: VariantType::Plural,
    default_pattern,
    default_expression,
    match_arms,
    })
    }