diff --git a/CHANGELOG.md b/CHANGELOG.md index 7390fcf..96c5435 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,8 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +- Add support for Fluent message attributes via dot notation (e.g., `bundle.get_translation("message.attribute")`). + ## [0.1.0a8] - 2025-10-01 - Annotate `ftl` file source code when reporting parse errors to allow ergonomic debugging. diff --git a/src/lib.rs b/src/lib.rs index 7decb6e..4e4b52f 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -77,14 +77,29 @@ mod rustfluent { ) -> PyResult { self.bundle.set_use_isolating(use_isolating); - let msg = self - .bundle - .get_message(identifier) - .ok_or_else(|| (PyValueError::new_err(format!("{identifier} not found"))))?; + let get_message = |id: &str| { + self.bundle + .get_message(id) + .ok_or_else(|| PyValueError::new_err(format!("{id} not found"))) + }; - let pattern = msg.value().ok_or_else(|| { - PyValueError::new_err(format!("{identifier} - Message has no value.",)) - })?; + let pattern = match identifier.split_once('.') { + Some((message_id, attribute_id)) => get_message(message_id)? + .get_attribute(attribute_id) + .ok_or_else(|| { + PyValueError::new_err(format!( + "{identifier} - Attribute '{attribute_id}' not found on message '{message_id}'." + )) + })? + .value(), + // Note: attribute.value() returns &Pattern directly (not Option) + // because attributes always have values, unlike messages + None => get_message(identifier)? + .value() + .ok_or_else(|| { + PyValueError::new_err(format!("{identifier} - Message has no value.")) + })? + }; let mut args = FluentArgs::new(); diff --git a/tests/data/attributes.ftl b/tests/data/attributes.ftl new file mode 100644 index 0000000..dd1ebe5 --- /dev/null +++ b/tests/data/attributes.ftl @@ -0,0 +1,20 @@ +# Test file for Fluent attributes +welcome-message = Welcome! + .title = Welcome to our site + .aria-label = Welcome greeting + +login-input = Email + .placeholder = email@example.com + .aria-label = Login input value + .title = Type your login email + +# Message with variables in attributes +greeting = Hello + .formal = Hello, { $name } + .informal = Hi { $name }! + +# Message with only attributes (no value) +form-button = + .submit = Submit Form + .cancel = Cancel + .reset = Reset Form diff --git a/tests/test_python_interface.py b/tests/test_python_interface.py index ce47b9d..9c551be 100644 --- a/tests/test_python_interface.py +++ b/tests/test_python_interface.py @@ -207,3 +207,84 @@ def test_raises_parser_error_on_file_that_contains_errors_in_strict_mode(): def test_parser_error_str(): assert str(fluent.ParserError) == "" + + +# Attribute access tests + + +def test_basic_attribute_access(): + bundle = fluent.Bundle("en", [data_dir / "attributes.ftl"]) + assert bundle.get_translation("welcome-message.title") == "Welcome to our site" + + +def test_regular_message_still_works_with_attributes(): + """Test that accessing the main message value still works when it has attributes.""" + bundle = fluent.Bundle("en", [data_dir / "attributes.ftl"]) + assert bundle.get_translation("welcome-message") == "Welcome!" + + +def test_multiple_attributes_on_same_message(): + bundle = fluent.Bundle("en", [data_dir / "attributes.ftl"]) + assert bundle.get_translation("login-input.placeholder") == "email@example.com" + assert bundle.get_translation("login-input.aria-label") == "Login input value" + assert bundle.get_translation("login-input.title") == "Type your login email" + + +def test_attribute_with_variables(): + bundle = fluent.Bundle("en", [data_dir / "attributes.ftl"]) + result = bundle.get_translation("greeting.formal", variables={"name": "Alice"}) + assert result == f"Hello, {BIDI_OPEN}Alice{BIDI_CLOSE}" + + +def test_attribute_with_variables_use_isolating_off(): + bundle = fluent.Bundle("en", [data_dir / "attributes.ftl"]) + result = bundle.get_translation( + "greeting.informal", + variables={"name": "Bob"}, + use_isolating=False, + ) + assert result == "Hi Bob!" + + +def test_attribute_on_message_without_main_value(): + bundle = fluent.Bundle("en", [data_dir / "attributes.ftl"]) + assert bundle.get_translation("form-button.submit") == "Submit Form" + assert bundle.get_translation("form-button.cancel") == "Cancel" + assert bundle.get_translation("form-button.reset") == "Reset Form" + + +def test_message_without_value_raises_error(): + """Test that accessing a message without a value (only attributes) raises an error.""" + bundle = fluent.Bundle("en", [data_dir / "attributes.ftl"]) + with pytest.raises(ValueError, match="form-button - Message has no value"): + bundle.get_translation("form-button") + + +def test_missing_message_with_attribute_syntax_raises_error(): + bundle = fluent.Bundle("en", [data_dir / "attributes.ftl"]) + with pytest.raises(ValueError, match="nonexistent not found"): + bundle.get_translation("nonexistent.title") + + +def test_missing_attribute_raises_error(): + bundle = fluent.Bundle("en", [data_dir / "attributes.ftl"]) + with pytest.raises( + ValueError, + match="welcome-message.nonexistent - Attribute 'nonexistent' not found on message 'welcome-message'", + ): + bundle.get_translation("welcome-message.nonexistent") + + +@pytest.mark.parametrize( + "identifier,expected", + ( + ("welcome-message", "Welcome!"), + ("welcome-message.title", "Welcome to our site"), + ("welcome-message.aria-label", "Welcome greeting"), + ("login-input", "Email"), + ("login-input.placeholder", "email@example.com"), + ), +) +def test_attribute_and_message_access_parameterized(identifier, expected): + bundle = fluent.Bundle("en", [data_dir / "attributes.ftl"]) + assert bundle.get_translation(identifier) == expected