Repository: projectfluent/python-fluent (language: python)

Issue:
Ordinals do not work
The [docs](https://projectfluent.org/fluent/guide/selectors.html) give the following example:

```
your-rank = { NUMBER($pos, type: "ordinal") ->
   [1] You finished first!
   [one] You finished {$pos}st
   [two] You finished {$pos}nd
   [few] You finished {$pos}rd
  *[other] You finished {$pos}th
}
```

Checking via the [online editor](https://projectfluent.org/play/) I see we get the following strings:
```
1: You finished first!
2: You finished 2nd
3: You finished 3rd
4: You finished 4th
5: You finished 5th
…
20: You finished 20th
21: You finished 21st
22: You finished 22nd
23: You finished 23rd
24: You finished 24rd
25: You finished 25rd
```

But the python implementation seems to not recognise the `type: "ordinal"` on the selector and we always get the “other” case:
```
>>> from fluent.runtime import FluentLocalization, FluentResourceLoader
>>> l10n = FluentLocalization(['en'], ['test.ftl'], FluentResourceLoader('{locale}'))
>>> l10n.format_value('your-rank', {'pos': 1})
'You finished 1th'
>>> l10n.format_value('your-rank', {'pos': 2})
'You finished 2th'
>>> l10n.format_value('your-rank', {'pos': 3})
'You finished 3th'
>>> l10n.format_value('your-rank', {'pos': 5})
'You finished 5th'
>>> l10n.format_value('your-rank', {'pos': 21})
'You finished 21th'
>>> l10n.format_value('your-rank', {'pos': 22})
'You finished 22th'
>>> l10n.format_value('your-rank', {'pos': 23})
'You finished 23th'
```


Discussion / hints:

Fix patch:
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,


Test patch:
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)