diff --git a/fluent.runtime/fluent/runtime/bundle.py b/fluent.runtime/fluent/runtime/bundle.py index 45e32852..186a7be5 100644 --- a/fluent.runtime/fluent/runtime/bundle.py +++ b/fluent.runtime/fluent/runtime/bundle.py @@ -47,6 +47,8 @@ def __init__(self, self._babel_locale = self._get_babel_locale() self._plural_form = cast(Callable[[Any], Callable[[Union[int, float]], PluralCategory]], babel.plural.to_python)(self._babel_locale.plural_form) + self._ordinal_form = cast(Callable[[Any], Callable[[Union[int, float]], PluralCategory]], + babel.plural.to_python)(self._babel_locale.ordinal_form) def add_resource(self, resource: FTL.Resource, allow_overrides: bool = False) -> None: # TODO - warn/error about duplicates diff --git a/fluent.runtime/fluent/runtime/resolver.py b/fluent.runtime/fluent/runtime/resolver.py index e4d0caa5..7782e3a9 100644 --- a/fluent.runtime/fluent/runtime/resolver.py +++ b/fluent.runtime/fluent/runtime/resolver.py @@ -319,7 +319,11 @@ def match(val1: Any, val2: Any, env: ResolverEnvironment) -> bool: if is_number(val1): if not is_number(val2): # Could be plural rule match - return cast(bool, env.context._plural_form(val1) == val2) + if isinstance(val1, (FluentInt, FluentFloat)) and val1.options.type == 'ordinal': + val1_form = env.context._ordinal_form(val1) + else: + val1_form = env.context._plural_form(val1) + return cast(bool, val1_form == val2) elif is_number(val2): return match(val2, val1, env) diff --git a/fluent.runtime/fluent/runtime/types.py b/fluent.runtime/fluent/runtime/types.py index e57e653c..a27d6ec6 100644 --- a/fluent.runtime/fluent/runtime/types.py +++ b/fluent.runtime/fluent/runtime/types.py @@ -28,6 +28,13 @@ CURRENCY_DISPLAY_NAME, } +NUMBER_TYPE_ORDINAL = "ordinal" +NUMBER_TYPE_CARDINAL = "cardinal" +NUMBER_TYPE_OPTIONS = { + NUMBER_TYPE_ORDINAL, + NUMBER_TYPE_CARDINAL, +} + DATE_STYLE_OPTIONS = { "full", "long", @@ -71,6 +78,9 @@ class NumberFormatOptions: style: Literal['decimal', 'currency', 'percent'] = attr.ib( default=FORMAT_STYLE_DECIMAL, validator=attr.validators.in_(FORMAT_STYLE_OPTIONS)) + type: Literal['ordinal', 'cardinal'] = attr.ib( + default=NUMBER_TYPE_CARDINAL, + validator=attr.validators.in_(NUMBER_TYPE_OPTIONS)) currency: Union[str, None] = attr.ib(default=None) currencyDisplay: Literal['symbol', 'code', 'name'] = attr.ib( default=CURRENCY_DISPLAY_SYMBOL, diff --git a/fluent.runtime/tests/format/test_primitives.py b/fluent.runtime/tests/format/test_primitives.py index 5e183f40..b0bf83d3 100644 --- a/fluent.runtime/tests/format/test_primitives.py +++ b/fluent.runtime/tests/format/test_primitives.py @@ -128,6 +128,10 @@ def setUp(self): *[0] Zero [1] One } + position = { NUMBER(1, type: "ordinal") -> + *[other] Zero + [one] ${1}st + } """))) def test_int_number_used_in_placeable(self): diff --git a/fluent.runtime/tests/format/test_select_expression.py b/fluent.runtime/tests/format/test_select_expression.py index 0101da54..993a5c62 100644 --- a/fluent.runtime/tests/format/test_select_expression.py +++ b/fluent.runtime/tests/format/test_select_expression.py @@ -135,6 +135,18 @@ def setUp(self): [one] A *[other] B } + + count = { NUMBER($num, type: "cardinal") -> + *[other] B + [one] A + } + + order = { NUMBER($num, type: "ordinal") -> + *[other] {$num}th + [one] {$num}st + [two] {$num}nd + [few] {$num}rd + } """))) def test_selects_the_right_category(self): @@ -172,6 +184,42 @@ def test_with_argument_float(self): self.assertEqual(val, "A") self.assertEqual(len(errs), 0) + def test_with_cardinal_integer(self): + val, errs = self.bundle.format_pattern(self.bundle.get_message('count').value, {'num': 1}) + self.assertEqual(val, "A") + self.assertEqual(len(errs), 0) + + val, errs = self.bundle.format_pattern(self.bundle.get_message('count').value, {'num': 2}) + self.assertEqual(val, "B") + self.assertEqual(len(errs), 0) + + def test_with_cardinal_float(self): + val, errs = self.bundle.format_pattern(self.bundle.get_message('count').value, {'num': 1.0}) + self.assertEqual(val, "A") + self.assertEqual(len(errs), 0) + + def test_with_ordinal_integer(self): + val, errs = self.bundle.format_pattern(self.bundle.get_message('order').value, {'num': 1}) + self.assertEqual(val, "1st") + self.assertEqual(len(errs), 0) + + val, errs = self.bundle.format_pattern(self.bundle.get_message('order').value, {'num': 2}) + self.assertEqual(val, "2nd") + self.assertEqual(len(errs), 0) + + val, errs = self.bundle.format_pattern(self.bundle.get_message('order').value, {'num': 11}) + self.assertEqual(val, "11th") + self.assertEqual(len(errs), 0) + + val, errs = self.bundle.format_pattern(self.bundle.get_message('order').value, {'num': 21}) + self.assertEqual(val, "21st") + self.assertEqual(len(errs), 0) + + def test_with_ordinal_float(self): + val, errs = self.bundle.format_pattern(self.bundle.get_message('order').value, {'num': 1.0}) + self.assertEqual(val, "1st") + self.assertEqual(len(errs), 0) + class TestSelectExpressionWithTerms(unittest.TestCase): diff --git a/fluent.runtime/tests/test_bundle.py b/fluent.runtime/tests/test_bundle.py index 19bf9612..27946fb2 100644 --- a/fluent.runtime/tests/test_bundle.py +++ b/fluent.runtime/tests/test_bundle.py @@ -75,6 +75,21 @@ def test_plural_form_french(self): self.assertEqual(bundle._plural_form(2), 'other') + def test_ordinal_form_english_ints(self): + bundle = FluentBundle(['en-US']) + self.assertEqual(bundle._ordinal_form(0), + 'other') + self.assertEqual(bundle._ordinal_form(1), + 'one') + self.assertEqual(bundle._ordinal_form(2), + 'two') + self.assertEqual(bundle._ordinal_form(3), + 'few') + self.assertEqual(bundle._ordinal_form(11), + 'other') + self.assertEqual(bundle._ordinal_form(21), + 'one') + def test_format_args(self): self.bundle.add_resource(FluentResource('foo = Foo')) val, errs = self.bundle.format_pattern(self.bundle.get_message('foo').value)