Skip to content
Merged
1 change: 1 addition & 0 deletions docs/changes.rst
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ Weblate 2026.5
* :ref:`code-hosting-gerrit` branch validation now suggests short branch names when full refs are supplied.
* Malformed ALTCHA CAPTCHA submissions no longer cause server errors.
* Malformed repository URLs in webhook payloads no longer trigger server errors during fallback matching.
* :ref:`check-placeholders` now merges overlapping non-nested spans from multiple flags.
* :ref:`backup` logs no longer include OpenSSH post-quantum key exchange warnings from remote Borg connections.
* Locked component pages now show an unsubscribe action after subscribing to unlock notifications.

Expand Down
27 changes: 24 additions & 3 deletions weblate/checks/placeholders.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@

from weblate.checks.base import TargetCheckParametrized
from weblate.checks.parser import multi_value_flag, single_value_flag
from weblate.checks.utils import merge_highlight_spans

if TYPE_CHECKING:
from weblate.trans.models import Unit
Expand Down Expand Up @@ -50,7 +51,7 @@
)

@property
def param_type(self):

Check failure on line 54 in weblate/checks/placeholders.py

View workflow job for this annotation

GitHub Actions / mypy

Cannot override writeable attribute with read-only property
return parse_placeholders

def get_value(self, unit: Unit):
Expand Down Expand Up @@ -131,10 +132,30 @@
if not self.has_value(unit):
return

regexp = self.get_value(unit)
regex_flags = regex.IGNORECASE if "case-insensitive" in unit.all_flags else 0
spans: list[tuple[int, int, str]] = []

# get raw list of patterns from unit to run each independently continue
for param in unit.all_flags.get_value_raw(self.enable_string):
if isinstance(param, str):
if not param:
continue
pattern = regex.compile(regex.escape(param), regex_flags)
else:
if not param.pattern:
continue
pattern = regex.compile(param.pattern, regex_flags)

spans.extend(
(match.start(), match.end(), match.group())
for match in pattern.finditer(source)
)

Comment thread
nijel marked this conversation as resolved.
if not spans:
return

for match in regexp.finditer(source):
yield (match.start(), match.end(), match.group())
spans.sort(key=lambda x: (x[0], -x[1]))
yield from merge_highlight_spans(source, spans)

def get_description(self, check_obj):
unit = check_obj.unit
Expand Down Expand Up @@ -170,7 +191,7 @@
)

@property
def param_type(self):

Check failure on line 194 in weblate/checks/placeholders.py

View workflow job for this annotation

GitHub Actions / mypy

Cannot override writeable attribute with read-only property
return single_value_flag(parse_regex)

def check_target_params(
Expand Down
15 changes: 15 additions & 0 deletions weblate/checks/tests/test_placeholders.py
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,21 @@ def test_escaped_markup(self) -> None:
],
)

def test_overlapping_non_nested(self) -> None:
# The 2 flags match partially overlapping spans
# 'python-brace-format' matches {user.name}.
# "placeholders:r"\$\{\w+" matches ${user.
unit = make_unit(
None,
r'placeholders:r"\$\{\w+":r"\w+\.\w+\}"',
self.default_lang,
"nested ${user.name} non-overlapping",
)
self.assertEqual(
list(self.check.check_highlight(unit.source, unit)),
[(7, 19, "${user.name}")],
)

def test_empty_placeholder_flags_do_not_match(self) -> None:
for flags in ("placeholders:", 'placeholders:""', 'placeholders:r""'):
with self.subTest(flags=flags):
Expand Down
13 changes: 13 additions & 0 deletions weblate/checks/tests/test_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,19 @@ def test_overlap(self) -> None:
[(7, 26, '<a href="{format}">'), (32, 36, "</a>")],
)

def test_overlap_non_nested(self) -> None:
# The 2 flags match partially overlapping spans
# 'python-brace-format' matches {user.name}.
# "placeholders:r"\$\{\w+" matches ${user.
unit = make_unit(
source="nested ${user.name} non-overlapping",
flags=r'python-brace-format, placeholders:r"\$\{\w+"',
)
self.assertEqual(
highlight_string(unit.source, unit),
[(7, 19, "${user.name}")],
)

def test_syntax(self) -> None:
unit = make_unit(
source="Text with a `link <https://www.sphinx-doc.org>`_.",
Expand Down
38 changes: 17 additions & 21 deletions weblate/checks/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,21 @@ def highlight_pygments(source: str, unit: Unit) -> Generator[tuple[int, int, str
start += len(text)


def merge_highlight_spans(
source: str, highlights: list[tuple[int, int, str]]
) -> list[tuple[int, int, str]]:
"""Merge overlapping highlight spans (nested or partial) into their union intervals."""
merged: list[tuple[int, int, str]] = []
for start, end, text in highlights:
Comment thread
nijel marked this conversation as resolved.
if merged and start < merged[-1][1]:
prev_start, prev_end, _ = merged[-1]
new_end = max(prev_end, end)
merged[-1] = (prev_start, new_end, source[prev_start:new_end])
else:
merged.append((start, end, text))
return merged


def highlight_string(
source: str, unit: Unit, *, highlight_syntax: bool = False
) -> list[tuple[int, int, str]]:
Expand All @@ -67,27 +82,7 @@ def highlight_string(
# Sort by order in string, longest first
highlights.sort(key=lambda item: (item[0], -item[1]))

# Remove overlapping ones
# pylint: disable-next=consider-using-enumerate
for hl_idx in range(len(highlights)):
if hl_idx >= len(highlights):
break
elref = highlights[hl_idx]
hl_idx_next = hl_idx + 1
while hl_idx_next < len(highlights):
eltest = highlights[hl_idx_next]
if eltest[0] >= elref[0] and eltest[1] <= elref[1]:
# Elements overlap, remove inner one
highlights.pop(hl_idx_next)
# Do not increment index here as we've removed the current element
elif eltest[0] > elref[1]:
# This is not an overlapping element
break
else:
# Increase index to test
hl_idx_next += 1

return highlights
return merge_highlight_spans(source, highlights)


def replace_highlighted(
Expand All @@ -106,6 +101,7 @@ def replace_highlighted(
last_end = 0
for start, end, _text in highlights:
if start < last_end:
last_end = max(last_end, end)
continue
result.append(source[last_end:start])
if callable(replacement):
Expand Down
Loading