Repository: mozilla/pontoon

Issue:
Pretranslate Fluent plural messages for each plural form used by the locale
Some locales have different plurals forms than English (one, many). We should take that into accounts when pretranslating plural messages and add, remove or modify plural forms in the pretranslation accordingly.

If plural message contains any literal variants (e.g. 1), we should preserve them in pretranslation.

We'll have to identify the right source plural form to be used as the source for each target plural form.


Discussion:

Resolving patch:
diff --git a/pontoon/base/fluent.py b/pontoon/base/fluent.py
--- a/pontoon/base/fluent.py
+++ b/pontoon/base/fluent.py
@@ -1,3 +1,4 @@
+import copy
 import re
 
 from fluent.syntax import ast, FluentParser, visitor
@@ -158,3 +159,49 @@ def get_simple_preview(content):
         tree = translation_ast.attributes[0]
 
     return serialize_value(tree.value)
+
+
+def is_plural_expression(expression):
+    from pontoon.base.models import Locale
+
+    CLDR_PLURALS = [c for _, c in Locale.CLDR_PLURALS]
+
+    if isinstance(expression, ast.SelectExpression):
+        return all(
+            isinstance(variant.key, ast.NumberLiteral)
+            or variant.key.name in CLDR_PLURALS
+            for variant in expression.variants
+        )
+
+    return False
+
+
+def create_locale_plural_variants(node, locale):
+    if not is_plural_expression(node):
+        return
+
+    variants = []
+    source_plurals = {}
+    default = None
+
+    for variant in node.variants:
+        key = variant.key
+        if isinstance(key, ast.NumberLiteral):
+            variants.append(variant)
+        else:
+            source_plurals[key.name] = variant
+        if variant.default:
+            default = variant
+
+    for plural in locale.cldr_plurals_list():
+        if plural in source_plurals.keys():
+            variant = source_plurals[plural]
+        else:
+            variant = copy.deepcopy(default)
+            variant.key.name = plural
+        variant.default = False
+        variants.append(variant)
+
+    variants[-1].default = True
+
+    node.variants = variants
diff --git a/pontoon/base/models.py b/pontoon/base/models.py
--- a/pontoon/base/models.py
+++ b/pontoon/base/models.py
@@ -907,7 +907,7 @@ def serialize(self):
             "pk": self.pk,
             "nplurals": self.nplurals,
             "plural_rule": self.plural_rule,
-            "cldr_plurals": self.cldr_plurals_list(),
+            "cldr_plurals": self.cldr_plurals_list_string(),
             "direction": self.direction,
             "script": self.script,
             "google_translate_code": self.google_translate_code,
@@ -923,7 +923,10 @@ def cldr_id_list(self):
             return [int(p) for p in self.cldr_plurals.split(",")]
 
     def cldr_plurals_list(self):
-        return ", ".join(map(Locale.cldr_id_to_plural, self.cldr_id_list()))
+        return map(Locale.cldr_id_to_plural, self.cldr_id_list())
+
+    def cldr_plurals_list_string(self):
+        return ", ".join(self.cldr_plurals_list())
 
     @classmethod
     def cldr_plural_to_id(self, cldr_plural):
diff --git a/pontoon/pretranslation/pretranslate.py b/pontoon/pretranslation/pretranslate.py
--- a/pontoon/pretranslation/pretranslate.py
+++ b/pontoon/pretranslation/pretranslate.py
@@ -9,7 +9,7 @@
 from functools import reduce
 
 from pontoon.base.models import User, TranslatedResource
-from pontoon.base.fluent import FlatTransformer
+from pontoon.base.fluent import FlatTransformer, create_locale_plural_variants
 from pontoon.machinery.utils import (
     get_google_translate_data,
     get_translation_memory_data,
@@ -27,6 +27,10 @@ def __init__(self, locale):
         self.services = []
         self.locale = locale
 
+    def visit_SelectExpression(self, node):
+        create_locale_plural_variants(node, self.locale)
+        return self.generic_visit(node)
+
     def visit_TextElement(self, node):
         pretranslation, service = get_pretranslated_data(node.value, self.locale)
 


Test patch:
diff --git a/pontoon/base/tests/test_fluent.py b/pontoon/base/tests/test_fluent.py
--- a/pontoon/base/tests/test_fluent.py
+++ b/pontoon/base/tests/test_fluent.py
@@ -1,13 +1,19 @@
 import pytest
 
 from collections import OrderedDict
-from fluent.syntax import FluentParser
+from fluent.syntax import FluentParser, FluentSerializer
 from textwrap import dedent
 
-from pontoon.base.fluent import FlatTransformer, get_simple_preview
+from pontoon.base.fluent import (
+    FlatTransformer,
+    get_simple_preview,
+    is_plural_expression,
+    create_locale_plural_variants,
+)
 
 
 parser = FluentParser()
+serializer = FluentSerializer()
 transformer = FlatTransformer()
 
 
@@ -193,3 +199,145 @@ def test_flat_transformer_value_and_attributes():
 def test_get_simple_preview(k):
     string, expected = SIMPLE_TRANSLATION_TESTS[k]
     assert get_simple_preview(string) == expected
+
+
+def test_is_plural_expression_not_select_expression():
+    # Return False for elements that are not select expressions
+    input = dedent(
+        """
+        my-entry = Hello!
+    """
+    )
+
+    message = parser.parse_entry(input)
+    element = message.value.elements[0]
+
+    assert is_plural_expression(element) is False
+
+
+def test_is_plural_expression_all_cldr_plurals():
+    # Return True if all variant keys are CLDR plurals
+    input = dedent(
+        """
+        my-entry =
+            { $num ->
+                [one] Hello!
+               *[two] World!
+            }`
+    """
+    )
+
+    message = parser.parse_entry(input)
+    element = message.value.elements[0]
+
+    assert is_plural_expression(element.expression) is True
+
+
+def test_is_plural_expression_all_numbers():
+    # Return True if all variant keys are numbers
+    input = dedent(
+        """
+        my-entry =
+            { $num ->
+                [1] Hello!
+               *[2] World!
+            }`
+    """
+    )
+
+    message = parser.parse_entry(input)
+    element = message.value.elements[0]
+
+    assert is_plural_expression(element.expression) is True
+
+
+def test_is_plural_expression_one_cldr_plural_one_number():
+    # Return True if one variant key is a CLDR plural and the other is a number
+    input = dedent(
+        """
+        my-entry =
+            { $num ->
+                [one] Hello!
+               *[1] World!
+            }`
+    """
+    )
+
+    message = parser.parse_entry(input)
+    element = message.value.elements[0]
+
+    assert is_plural_expression(element.expression) is True
+
+
+def test_is_plural_expression_one_cldr_plural_one_something_else():
+    # Return False if one variant key is a CLDR plural and the other is neither a CLDR plural nor a number
+    input = dedent(
+        """
+        my-entry =
+            { $num ->
+                [one] Hello!
+               *[variant] World!
+            }`
+    """
+    )
+
+    message = parser.parse_entry(input)
+    element = message.value.elements[0]
+
+    assert is_plural_expression(element.expression) is False
+
+
+def test_is_plural_expression_neither_cldr_plural_nor_number():
+    # Return False if neither variant key is a CLDR plural nor a number
+    input = dedent(
+        """
+        my-entry =
+            { $num ->
+                [variant] Hello!
+               *[another-variant] World!
+            }`
+    """
+    )
+
+    message = parser.parse_entry(input)
+    element = message.value.elements[0]
+
+    assert is_plural_expression(element.expression) is False
+
+
+@pytest.mark.django_db
+def test_create_locale_plural_variants(locale_a):
+    # Create default locale plural variants
+    input = dedent(
+        """
+        my-entry =
+            { $num ->
+                [0] Yo!
+                [one] Hello!
+               *[other] { reference } World!
+            }
+    """
+    )
+
+    message = parser.parse_entry(input)
+    expression = message.value.elements[0].expression
+
+    locale_a.cldr_plurals = "1,2,3,5"
+    create_locale_plural_variants(expression, locale_a)
+
+    expected = dedent(
+        """
+        my-entry =
+            { $num ->
+                [0] Yo!
+                [one] Hello!
+                [two] { reference } World!
+                [few] { reference } World!
+               *[other] { reference } World!
+            }
+    """
+    )
+
+    assert serializer.serialize_entry(message) == serializer.serialize_entry(
+        parser.parse_entry(expected)
+    )
diff --git a/pontoon/pretranslation/tests/test_pretranslate.py b/pontoon/pretranslation/tests/test_pretranslate.py
--- a/pontoon/pretranslation/tests/test_pretranslate.py
+++ b/pontoon/pretranslation/tests/test_pretranslate.py
@@ -88,7 +88,7 @@ def test_get_pretranslations_plurals(
     }
 
     entity_a.string_plural = entity_a.string
-    google_translate_locale.cldr_plurals = "1, 2"
+    google_translate_locale.cldr_plurals = "1,2"
 
     response = get_pretranslations(entity_a, google_translate_locale)
     assert response == [
@@ -171,6 +171,51 @@ def test_get_pretranslations_fluent_whitespace(
     assert response == [(pretranslated_string, None, tm_user)]
 
 
+@patch("pontoon.pretranslation.pretranslate.get_google_translate_data")
+@pytest.mark.django_db
+def test_get_pretranslations_fluent_plural(
+    gt_mock, fluent_resource, google_translate_locale, gt_user
+):
+    # Various types of whitespace should be preserved
+    gt_mock.return_value = {
+        "status": True,
+        "translation": "GT",
+    }
+
+    google_translate_locale.cldr_plurals = "1,2,3,5"
+
+    input_string = dedent(
+        """
+        plural =
+            { $count ->
+                [0] No matches found.
+                [one] One match found.
+                *[other] { $count } matches found.
+            }
+    """
+    )
+    fluent_entity = EntityFactory(resource=fluent_resource, string=input_string)
+
+    expected = dedent(
+        """
+        plural =
+            { $count ->
+                [0] GT
+                [one] GT
+                [two] GT
+                [few] GT
+                *[other] GT
+            }
+    """
+    )
+
+    # Re-serialize to match whitespace
+    pretranslated_string = serializer.serialize_entry(parser.parse_entry(expected))
+
+    response = get_pretranslations(fluent_entity, google_translate_locale)
+    assert response == [(pretranslated_string, None, gt_user)]
+
+
 @patch("pontoon.pretranslation.pretranslate.get_google_translate_data")
 @pytest.mark.django_db
 def test_get_pretranslations_fluent_complex(