Add rudimentary support for built-in `LEN()` function

finchie
Aug 18, 2025, 7:29 AM
XPGOKS6XWM2Q2R74DDEHQRMZH6BWQH2FMQGAXBLO7FW52J3VFKSQC

Dependencies

  • [2] XDJBTEXU Add support for integer selectors
  • [3] D652S2N3 Rename `parse` module to `parse_fluent`
  • [4] CESJ4CTO Move macro-specific code into `macro_impl` module
  • [5] F5LG7WEN Emit compilation errors from Fluent source code
  • [6] 3NMKD6I5 Refactor `Localize` trait to use `std::io::Write`
  • [7] QFPQZR4K Refactor `fluent_embed`
  • [8] 5TEX4MNU Split `fluent_embed` into `group` and `parse` modules
  • [*] VNSHGQYN Support using glob paths in `localize` macro
  • [*] XEEXWJLG Add simple end-to-end test for selectors
  • [*] 2SITVDYW Handle common errors in Fluent code

Change contents

  • file addition: functions.ftl (----------)
    [10.990]
    length = Item count: { LEN($items) }
    numbers = { LEN($items) ->
    [0] No items
    [1] One item
    *[other] { LEN($items) } items
    }
    plurals = { LEN($items) ->
    [one] One item
    *[other] { LEN($items) } items
    }
  • file addition: functions.rs (----------)
    [11.101]
    //! End-to-end test for function support in the `l10n_embed_derive` macro
    mod common;
    use common::compare_message;
    use icu_locale::{Locale, locale};
    use l10n_embed_derive::localize;
    use rstest::rstest;
    const DEFAULT_LOCALE: Locale = locale!("en-US");
    #[localize("tests/locale/**/functions.ftl")]
    pub enum MessageEnum {
    Length { items: Vec<String> },
    Numbers { items: Vec<String> },
    Plurals { items: Vec<String> },
    }
    #[localize("tests/locale/**/functions.ftl")]
    pub struct Length {
    items: Vec<String>,
    }
    #[localize("tests/locale/**/functions.ftl")]
    pub struct Numbers {
    items: Vec<String>,
    }
    #[localize("tests/locale/**/functions.ftl")]
    pub struct Plurals {
    items: Vec<String>,
    }
    #[rstest]
    #[case::zero(0)]
    #[case::one(1)]
    #[case::two(2)]
    fn len(#[case] item_count: usize) {
    let items: Vec<String> = (0..item_count)
    .map(|index| format!("Item {index}"))
    .collect();
    compare_message(
    MessageEnum::Length {
    items: items.clone(),
    },
    format!("Item count: {item_count}"),
    DEFAULT_LOCALE,
    );
    compare_message(
    Length { items },
    format!("Item count: {item_count}"),
    DEFAULT_LOCALE,
    );
    }
    #[rstest]
    #[case::zero(0, "No items")]
    #[case::one(1, "One item")]
    #[case::two(2, "2 items")]
    fn numbers(#[case] item_count: usize, #[case] expected: &str) {
    let items: Vec<String> = (0..item_count)
    .map(|index| format!("Item {index}"))
    .collect();
    compare_message(
    MessageEnum::Numbers {
    items: items.clone(),
    },
    expected,
    DEFAULT_LOCALE,
    );
    compare_message(Numbers { items }, expected, DEFAULT_LOCALE);
    }
    #[rstest]
    #[case::zero(0, "0 items")]
    #[case::one(1, "One item")]
    #[case::two(2, "2 items")]
    fn plurals(#[case] item_count: usize, #[case] expected: &str) {
    let items: Vec<String> = (0..item_count)
    .map(|index| format!("Item {index}"))
    .collect();
    compare_message(
    MessageEnum::Plurals {
    items: items.clone(),
    },
    expected,
    DEFAULT_LOCALE,
    );
    compare_message(Plurals { items }, expected, DEFAULT_LOCALE);
    }
  • edit in l10n_embed_derive/src/fluent/mod.rs at line 24
    [12.346]
    [3.1320]
    #[error("unable to parse Fluent function")]
    pub enum FunctionError {
    InvalidName {
    name: String,
    #[source_code]
    source_code: NamedSource<String>,
    // TODO: blocked on https://github.com/projectfluent/fluent-rs/pull/373
    // #[label("This key isn't in the `{canonical_locale}` locale")]
    // span: SourceSpan,
    },
    IncorrectPositionalArgumentCount {
    expected_len: usize,
    actual_len: usize,
    #[source_code]
    source_code: NamedSource<String>,
    // TODO: blocked on https://github.com/projectfluent/fluent-rs/pull/373
    // #[label("This key isn't in the `{canonical_locale}` locale")]
    // span: SourceSpan,
    },
    UnexpectedNamedArguments {
    #[source_code]
    source_code: NamedSource<String>,
    // TODO: blocked on https://github.com/projectfluent/fluent-rs/pull/373
    // #[label("This key isn't in the `{canonical_locale}` locale")]
    // span: SourceSpan,
    },
    NonVariableReference {
    #[source_code]
    source_code: NamedSource<String>,
    // TODO: blocked on https://github.com/projectfluent/fluent-rs/pull/373
    // #[label("This key isn't in the `{canonical_locale}` locale")]
    // span: SourceSpan,
    },
    }
    #[derive(Diagnostic, Debug, Error)]
  • edit in l10n_embed_derive/src/fluent/mod.rs at line 106
    [2.2860]
    [12.1364]
    #[error(transparent)]
    #[diagnostic(transparent)]
    Function(FunctionError),
  • replacement in l10n_embed_derive/src/fluent/ast.rs at line 3
    [3.856][3.6411:6447](),[3.33][3.6411:6447]()
    use super::{Error, MessageContext};
    [3.856]
    [3.6229]
    use super::{Error, FunctionError, MessageContext};
  • replacement in l10n_embed_derive/src/fluent/ast.rs at line 46
    [2.3751][2.3751:3966]()
    // Only dereference if enum variant
    let category_reference =
    match message_context.derive_context.reference_kind {
    [2.3751]
    [2.3966]
    let category_reference = match selector {
    // Can't dereference value returned by function
    InlineExpression::FunctionReference { .. } => {
    quote!(#raw_match_target)
    }
    // Only dereference if enum variant
    _ => match message_context.derive_context.reference_kind {
  • replacement in l10n_embed_derive/src/fluent/ast.rs at line 55
    [2.4159][2.4159:4198]()
    };
    [2.4159]
    [2.4198]
    },
    };
  • edit in l10n_embed_derive/src/fluent/ast.rs at line 288
    [3.2509]
    [3.2509]
    InlineExpression::FunctionReference { id, arguments } => {
    match id.name.to_snake_case().as_str() {
    "len" => {
    if !arguments.named.is_empty() {
    return Err(Error::Function(FunctionError::UnexpectedNamedArguments {
    source_code: message_context.source.named_source.clone(),
    }));
    }
    let argument = match arguments.positional.as_slice() {
    [single_argument] => single_argument,
    _ => {
    return Err(Error::Function(
    FunctionError::IncorrectPositionalArgumentCount {
    expected_len: 1,
    actual_len: arguments.positional.len(),
    source_code: message_context.source.named_source.clone(),
    },
    ));
    }
    };
    let variable_reference = match argument {
    InlineExpression::VariableReference { .. } => {
    inline_expression(argument, message_context)?
    }
    _ => {
    return Err(Error::Function(FunctionError::NonVariableReference {
    source_code: message_context.source.named_source.clone(),
    }));
    }
    };
    let vec_reference = match message_context.derive_context.reference_kind {
    ReferenceKind::EnumField => quote!(#variable_reference),
    ReferenceKind::StructField => quote!(&#variable_reference),
    };
    parse_quote!(Vec::len(#vec_reference))
    }
    _ => {
    return Err(Error::Function(FunctionError::InvalidName {
    name: id.name.clone(),
    source_code: message_context.source.named_source.clone(),
    }));
    }
    }
    }