diff --git a/pontoon/checks/libraries/custom.py b/pontoon/checks/libraries/custom.py
index 2f96510e3c..d3c160d280 100644
--- a/pontoon/checks/libraries/custom.py
+++ b/pontoon/checks/libraries/custom.py
@@ -258,6 +258,7 @@ def require_placeholders_match(
if src:
for pattern in get_patterns(src):
preview = ""
+ ph_spans: list[tuple[int, int, str]] = []
for el in pattern:
if isinstance(el, str):
if "%" in el:
@@ -272,10 +273,12 @@ def require_placeholders_match(
and el.function in (None, "html")
):
src_ph_strings.add(ps)
- required_ph.add(ps)
+ ph_spans.append((len(preview), len(preview) + len(ps), ps))
preview += ps
+ enclosed_spans: set[tuple[int, int, str]] = set()
elements: Counter[str] = Counter()
src_mismatched = mismatched_tags(preview)
+ # Put tags back together when placeholders split them into parts.
for pm in ph_re.finditer(preview):
if pm[0].startswith("<") and (
is_element(pm[0], preview) or pm.span() in src_mismatched
@@ -283,6 +286,18 @@ def require_placeholders_match(
src_ph_strings.add(pm[0])
required_ph.add(pm[0])
elements[pm[0]] += 1
+ enclosed_spans.update(
+ (start, end, ps)
+ for start, end, ps in ph_spans
+ if pm.start() <= start
+ and end <= pm.end()
+ and pm.span() != (start, end)
+ )
+ required_ph.update(
+ ps
+ for start, end, ps in ph_spans
+ if (start, end, ps) not in enclosed_spans
+ )
variant_counts = count_unnumbered_placeholders(preview)
src_max_counts |= variant_counts
src_min_counts = (
diff --git a/pontoon/checks/tests/test_custom.py b/pontoon/checks/tests/test_custom.py
index ff8a859679..b40d654d17 100644
--- a/pontoon/checks/tests/test_custom.py
+++ b/pontoon/checks/tests/test_custom.py
@@ -283,7 +283,32 @@ def test_android_changed_placeholder():
}
+def test_android_placeholder_in_element():
+ """Source XML: Read the <a href="%1$s">policy</a>"""
+ original = 'Read the policy{|| :html}'
+ entity = mock_entity("android", string=original)
+ assert run_custom_checks(entity, original) == {}
+
+
def test_android_changed_placeholder_in_element():
+ """Source XML: Read the <a href="%1$s">policy</a>
+
+ Translation XML: Leggi la <a href="https://example.com">policy</a>
+ """
+ original = 'Read the policy{|| :html}'
+ translation = 'Leggi la policy{|| :html}'
+ entity = mock_entity("android", string=original)
+ assert run_custom_checks(entity, translation) == {
+ "pErrors": ['Element not found in reference'],
+ "pWarnings": ['Element not found in translation'],
+ }
+
+
+def test_android_changed_placeholder_in_markup():
+ """Source XML: Read the policy
+
+ Translation XML: Leggi la policy
+ """
original = "Read the {#a href=|%1$s|}policy{/a}"
translation = "Leggi la {#a href=|https://example.com|}policy{/a}"
entity = mock_entity("android", string=original)
@@ -302,11 +327,19 @@ def test_android_changed_placeholder_in_element():
],
)
def test_android_unnumbered_placeholder_in_markup_attribute(original):
+ """Source XML: Hi %s link
+
+ Source XML: %s
+ """
entity = mock_entity("android", string=original)
assert run_custom_checks(entity, original) == {}
def test_android_extra_unnumbered_placeholder_with_markup_attribute():
+ """Source XML: Hi %s link
+
+ Translation XML: Hi %s %s link
+ """
original = "Hi {$arg :string @source=|%s|} {#a href=|%s|}link{/a}"
translation = (
"Hi {$arg :string @source=|%s|} {$arg2 :string @source=|%s|} "
@@ -321,6 +354,7 @@ def test_android_extra_unnumbered_placeholder_with_markup_attribute():
def test_android_paired_element_in_text():
+ """Source XML: Read the policy"""
original = "Read the {#b}policy{/b}"
entity = mock_entity("android", string=original)
assert run_custom_checks(entity, "Leggi la {#b}policy{/b}") == {}
@@ -382,7 +416,10 @@ def test_android_escaped_element_with_attributes():
def test_android_mismatched_elements_in_text():
- """Tags that don't pair up are markup, whatever they're named."""
+ """Tags that don't pair up are markup, whatever they're named.
+
+ Translation XML: <foo>testo</bar>
+ """
entity = mock_entity("android", string="text")
checks = run_custom_checks(entity, "{|| :html}testo{|| :html}")
assert sorted(checks["pErrors"]) == [
@@ -407,6 +444,9 @@ def test_xcode_literal_angle_brackets():
def test_android_protections_with_shared_substring():
+ """Source XML:
+ Hi Name and FullName
+ """
original = (
"Hi {$a :xliff:g id=a @translate=no @source=Name} "
"and {$b :xliff:g id=b @translate=no @source=FullName}"
@@ -421,12 +461,17 @@ def test_android_protections_with_shared_substring():
def test_android_multi_digit_placeholder():
+ """Source XML: Hi %10$s and %2$s"""
original = "Hi {$a :string @source=|%10$s|} and {$b :string @source=|%2$s|}"
entity = mock_entity("android", string=original)
assert run_custom_checks(entity, original) == {}
def test_android_changed_multi_digit_placeholder():
+ """Source XML: Hi %10$s and %2$s
+
+ Translation XML: Ciao %11$s e %2$s
+ """
original = "Hi {$a :string @source=|%10$s|} and {$b :string @source=|%2$s|}"
translation = "Ciao {$a :string @source=|%11$s|} e {$b :string @source=|%2$s|}"
entity = mock_entity("android", string=original)
@@ -437,6 +482,9 @@ def test_android_changed_multi_digit_placeholder():
def test_android_protection_matching_element_text():
+ """Source XML:
+ Hi Name, see <a title="Name">link</a>
+ """
original = (
"Hi {$a :xliff:g id=a @translate=no @source=Name}, "
'see {|| :html}link{|| :html}'
@@ -593,13 +641,40 @@ def test_xcode_extra_placeholder():
def test_xcode_html():
+ """Source XLIFF: Read the <b>policy</b>"""
original = "Read the policy"
translation = "Leggi la policy"
entity = mock_entity("xcode", string=original)
assert run_custom_checks(entity, translation) == {}
+def test_xcode_placeholder_in_element():
+ """Source XLIFF: Read the <a href="%1$@">policy</a>"""
+ original = 'Read the policy'
+ translation = 'Leggi la policy'
+ entity = mock_entity("xcode", string=original)
+ assert run_custom_checks(entity, translation) == {}
+
+
def test_xcode_changed_placeholder_in_element():
+ """Source XLIFF: Read the <a href="%1$@">policy</a>
+
+ Translation XLIFF: Leggi la <a href="https://example.com">policy</a>
+ """
+ original = 'Read the policy'
+ translation = 'Leggi la policy'
+ entity = mock_entity("xcode", string=original)
+ assert run_custom_checks(entity, translation) == {
+ "pErrors": ['Element not found in reference'],
+ "pWarnings": ['Element not found in translation'],
+ }
+
+
+def test_xcode_changed_placeholder_in_markup():
+ """Source XLIFF: Read the policy
+
+ Translation XLIFF: Leggi la policy
+ """
original = "Read the {#a href=|%1$@|}policy{/a}"
translation = "Leggi la {#a href=|https://example.com|}policy{/a}"
entity = mock_entity("xcode", string=original)
@@ -610,8 +685,15 @@ def test_xcode_changed_placeholder_in_element():
def test_xcode_placeholder_in_and_outside_element():
- original = "Visit {#a href=|%@|}{$arg2 :string @source=|%@|}{/a} for details"
- translation = "Click {#a href=|%@|}here{/a} for details"
+ """Source XLIFF: Visit <a href="%@">%@</a> for details
+
+ Translation XLIFF: Click <a href="%@">here</a> for details
+ """
+ original = (
+ 'Visit '
+ "{$arg2 :string @source=|%@|} for details"
+ )
+ translation = 'Click here for details'
entity = mock_entity("xcode", string=original)
assert run_custom_checks(entity, translation) == {
"pWarnings": ["Placeholder %@ not found in translation"]
@@ -624,6 +706,10 @@ def test_xcode_placeholder_in_and_outside_element():
)
@pytest.mark.parametrize("count", [0, 1, 2])
def test_repeated_unnumbered_placeholder(format, placeholder, count):
+ """Source XLIFF: Hello %@ and %@ (or %#x); Source XML: Hello %s and %s (or %02d)
+
+ Translation: Hello / Hello %@ / Hello %@ and %@
+ """
first = "{$arg1 :string @source=|" + placeholder + "|}"
second = "{$arg2 :string @source=|" + placeholder + "|}"
entity = mock_entity(format, string=f"Hello {first} and {second}")
@@ -652,17 +738,25 @@ def test_repeated_unnumbered_placeholder(format, placeholder, count):
],
)
def test_repeated_reusable_placeholder(format, placeholder):
+ """Source XLIFF: Hello %1$@ and %1$@ (or %#@items@); Source XML: Hello %1$s and %1$s
+
+ Translation: Hello %1$@
+ """
argument = "{$arg1 :string @source=|" + placeholder + "|}"
entity = mock_entity(format, string=f"Hello {argument} and {argument}")
assert run_custom_checks(entity, f"Hello {argument}") == {}
def test_repeated_unnumbered_placeholder_outside_element():
- element = "{#a href=|%@|}"
+ """Source XLIFF: <a href="%@">%@ and %@</a>
+
+ Translation XLIFF: <a href="%@">%@</a>
+ """
+ element = ''
first = "{$arg2 :string @source=|%@|}"
second = "{$arg3 :string @source=|%@|}"
- entity = mock_entity("xcode", string=f"{element}{first} and {second}{{/a}}")
- assert run_custom_checks(entity, f"{element}{first}{{/a}}") == {
+ entity = mock_entity("xcode", string=f"{element}{first} and {second}")
+ assert run_custom_checks(entity, f"{element}{first}") == {
"pWarnings": [
"Placeholder %@ has fewer occurrences in translation (expected 3, found 2)"
]
@@ -671,6 +765,11 @@ def test_repeated_unnumbered_placeholder_outside_element():
@pytest.mark.parametrize("missing", [False, True])
def test_repeated_unnumbered_placeholder_plural_variants(missing):
+ """Source XML: with "For %s and %s" for quantity one and other
+
+ Translation XML: with "For %s" (missing) or "For %s and %s" for
+ quantity one, few and other
+ """
first = "{$arg1 :string @source=|%s|}"
second = "{$arg2 :string @source=|%s|}"
original = (
@@ -700,6 +799,10 @@ def test_repeated_unnumbered_placeholder_plural_variants(missing):
def test_repeated_unnumbered_placeholder_extra_occurrence():
+ """Source XLIFF: Hello %@ and %@
+
+ Translation XLIFF: Hello %@, %@ and %@
+ """
first = "{$arg1 :string @source=|%@|}"
second = "{$arg2 :string @source=|%@|}"
third = "{$arg3 :string @source=|%@|}"
@@ -712,7 +815,12 @@ def test_repeated_unnumbered_placeholder_extra_occurrence():
def test_repeated_unnumbered_placeholder_fewer_source_variants():
- """Translations can have fewer plural forms than the source."""
+ """Translations can have fewer plural forms than the source.
+
+ Source XML: with "%s of %s" for quantity one and "%s items" for other
+
+ Translation XML: with "%s items" for quantity other
+ """
first = "{$arg1 :string @source=|%s|}"
second = "{$arg2 :string @source=|%s|}"
original = (
@@ -726,9 +834,13 @@ def test_repeated_unnumbered_placeholder_fewer_source_variants():
def test_unnumbered_placeholder_moved_outside_element():
+ """Source XLIFF: <a href="%@">%@</a>
+
+ Translation XLIFF: %@ %@
+ """
first = "{$arg1 :string @source=|%@|}"
second = "{$arg2 :string @source=|%@|}"
- entity = mock_entity("xcode", string=f"{{#a href=|%@|}}{second}{{/a}}")
+ entity = mock_entity("xcode", string=f'{second}')
checks = run_custom_checks(entity, f"{first} {second}")
assert list(checks) == ["pWarnings"]
assert sorted(checks["pWarnings"]) == [
@@ -739,7 +851,11 @@ def test_unnumbered_placeholder_moved_outside_element():
@pytest.mark.parametrize("source_count, target_count", [(2, 1), (1, 2), (2, 2)])
def test_repeated_element_with_unnumbered_placeholder(source_count, target_count):
- element = "{#a href=|%@|}link{/a}"
+ """Source XLIFF: <a href="%@">link</a>, repeated source_count times
+
+ Translation XLIFF: the same element, repeated target_count times
+ """
+ element = 'link'
entity = mock_entity("xcode", string=" ".join([element] * source_count))
expected = {}
if source_count != target_count:
@@ -755,14 +871,24 @@ def test_repeated_element_with_unnumbered_placeholder(source_count, target_count
def test_missing_element_suppresses_unnumbered_placeholder_warning():
- entity = mock_entity("xcode", string="Read {#a href=|%@|}link{/a}")
- assert run_custom_checks(entity, "Read link{/a}") == {
+ """Source XLIFF: Read <a href="%@">link</a>
+
+ Translation XLIFF: Read link</a>
+ """
+ entity = mock_entity(
+ "xcode", string='Read link'
+ )
+ assert run_custom_checks(entity, "Read link") == {
"pWarnings": ['Element not found in translation']
}
@pytest.mark.parametrize("count", [1, 3])
def test_unnumbered_placeholder_mismatch_in_one_plural_variant(count):
+ """Source stringsdict: "%@ %@" for one and other
+
+ Translation stringsdict: "%@" or "%@ %@ %@" for one, "%@ %@" for other
+ """
argument = "{$arg1 :string @source=|%@|}"
pair = argument + " " + argument
original = ".input {$n :number} .match $n one {{" + pair + "}} * {{" + pair + "}}"
@@ -780,11 +906,20 @@ def test_unnumbered_placeholder_mismatch_in_one_plural_variant(count):
def test_missing_elements_in_different_source_variants():
+ """Source stringsdict: <a href="%@">link</a> for one,
+ <a title="%@">link</a> for other
+
+ Translation stringsdict: link</a> for other
+ """
+ argument = "{$arg1 :string @source=|%@|}"
original = (
- ".input {$n :number} .match $n "
- "one {{{#a href=|%@|}link{/a}}} * {{{#a title=|%@|}link{/a}}}"
+ '.input {$n :number} .match $n one {{link}} * {{link}}'
)
- translation = ".input {$n :number} .match $n * {{link{/a}}}"
+ translation = ".input {$n :number} .match $n * {{link}}"
checks = run_custom_checks(mock_entity("xcode", string=original), translation)
assert list(checks) == ["pWarnings"]
assert sorted(checks["pWarnings"]) == [