Coverage for app/venv/lib/python3.14/site-packages/weblate/checks/format.py: 46%
338 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-07 07:15 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-07 07:15 +0000
1# Copyright © Michal Čihař <michal@weblate.org>
2#
3# SPDX-License-Identifier: GPL-3.0-or-later
5from __future__ import annotations
7import re
8from collections import Counter, defaultdict
9from typing import TYPE_CHECKING, ClassVar, Literal
11from django.utils.functional import SimpleLazyObject
12from django.utils.html import format_html, format_html_join
13from django.utils.safestring import mark_safe
14from django.utils.translation import gettext, gettext_lazy
16from weblate.checks.base import SourceCheck, TargetCheck
17from weblate.utils.html import format_html_join_comma, list_to_tuples
19if TYPE_CHECKING: 19 ↛ 20line 19 didn't jump to line 20 because the condition on line 19 was never true
20 from collections.abc import Callable, Iterable
21 from re import Pattern
23 from django_stubs_ext import StrOrPromise
25 from weblate.checks.base import MissingExtraDict
26 from weblate.trans.models import Unit
28 from .models import Check
31PYTHON_PRINTF_MATCH = re.compile(
32 r"""
33 %( # initial %
34 (?:\((?P<key>[^)]+)\))? # Python style variables, like %(var)s
35 (?P<fullvar>
36 [ +#-]* # flags
37 (?:\d+)? # width
38 (?:\.\d+)? # precision
39 (hh|h|l|ll)? # length formatting
40 (?P<type>[a-zA-Z%]) # type (%s, %d, etc.)
41 |) # incomplete format string
42 )""",
43 re.VERBOSE,
44)
46SCHEME_PRINTF_MATCH = re.compile(
47 r"""
48 ~( # initial ~
49 (?:(?P<ord>\d+)@\*~)? # variable order, like ~1@*~d
50 (?P<fullvar>
51 (?: # any number of comma-separated parameters
52 #([+-]?\d+|\'.|[vV]|#)
53 ([+-]?\d+|'.|[vV]|\#)
54 (, ([+-]?\d+|'.|[vV]|\#))*
55 )?
56 :?
57 @?
58 (?P<type>[a-zA-Z%\$\?&_/|!\[\]\(\)~]) # type (~a, ~s, etc.)
59 |) # incomplete format string
60 )""",
61 re.VERBOSE,
62)
65PHP_PRINTF_MATCH = re.compile(
66 r"""
67 %( # initial %
68 (?:(?P<ord>\d+)\$)? # variable order, like %1$s
69 (?P<fullvar>
70 [ +#-]* # flags
71 (?:\d+)? # width
72 (?:\.\d+)? # precision
73 (hh|h|l|ll)? # length formatting
74 (?P<type>[a-zA-Z%]) # type (%s, %d, etc.)
75 |) # incomplete format string
76 )""",
77 re.VERBOSE,
78)
81C_PRINTF_MATCH = re.compile(
82 r"""
83 %( # initial %
84 (?:(?P<ord>\d+)\$)? # variable order, like %1$s
85 (?P<fullvar>
86 [ +#'-]* # flags
87 (?:\d+)? # width
88 (?:\.\d+)? # precision
89 (hh|h|l|ll)? # length formatting
90 (?P<type>[a-zA-Z%]) # type (%s, %d, etc.)
91 |) # incomplete format string
92 )""",
93 re.VERBOSE,
94)
96# index, width and precision can be '*', in which case their value
97# will be read from the next element in the Args array
98PASCAL_FORMAT_MATCH = re.compile(
99 r"""
100 %( # initial %
101 (?:(?P<ord>\*|\d+):)? # variable index, like %0:s
102 (?P<fullvar>
103 -? # left align
104 (?:\*|\d+)? # width
105 (\.(?:\*|\d+))? # precision
106 (?P<type>[defgmnpsuxDEFGMNPSUX%]) # type (%s, %d, etc.)
107 |) # incomplete format string
108 )""",
109 re.VERBOSE,
110)
112PYTHON_BRACE_MATCH = re.compile(
113 r"""
114 }(})| # escaped {
115 {({)| # escaped }
116 {( # initial {
117 | # blank for position based
118 (?P<field>
119 [0-9]+| # numerical
120 [_A-Za-z][_0-9A-Za-z]* # identifier
121 )
122 (?P<attr>
123 \.[_A-Za-z][_0-9A-Za-z]* # attribute identifier
124 |\[[^]]+\] # index identifier
126 )*
127 (?P<conversion>
128 ![rsa]
129 )?
130 (?P<format_spec>
131 :
132 .? # fill
133 [<>=^]? # align
134 [+ -]? # sign
135 [#]? # alternate
136 0? # 0 prefix
137 (?:[1-9][0-9]*)? # width
138 ,? # , separator
139 (?:\.[1-9][0-9]*)? # precision
140 [bcdeEfFgGnosxX%]? # type
141 )?
142 )} # trailing }
143 """,
144 re.VERBOSE,
145)
147PERL_BRACE_MATCH = re.compile(r"({([a-zA-Z0-9_]+)})")
149C_SHARP_MATCH = re.compile(
150 r"""
151 { # initial {
152 (?P<arg>\d+) # variable order
153 (?P<width>
154 [-,?\s]+ # flags
155 (?:\d+)? # width
156 (?:\.\d+)? # precision
157 )?
158 (?P<format>
159 : # ':' identifier
160 ((
161 [a-zA-Z0#.,\s]* # type
162 (?:\d+)? # numerical
163 ))?
164 )?
165 } # Ending }
166 """,
167 re.VERBOSE,
168)
170JAVA_MATCH = re.compile(
171 r"""
172 %((?![\s]) # initial % (no space after)
173 (?:(?P<ord>\d+)\$)? # variable order, like %1$s
174 (?P<fullvar>
175 [-.#+0,(]* # flags
176 (?:\d+)? # width
177 (?:\.\d+)? # precision
178 (?P<type>
179 ((?<![tT])[tT][A-Za-z]|[A-Za-z])|%) # type (%s, %d, %td, etc.)
180 )
181 )
182 """,
183 re.VERBOSE,
184)
186JAVA_MESSAGE_MATCH = re.compile(
187 r"""
188 { # initial {
189 (?P<arg>\d+) # variable order
190 \s*
191 (
192 ,\s*(?P<format>[a-z]+) # format type
193 (,\s*(?P<style>\S+))? # format style
194 )?
195 \s*
196 } # Ending }
197 """,
198 re.VERBOSE,
199)
201I18NEXT_MATCH = re.compile(
202 r"""
203 (
204 \$t\((.+?)\) # nesting
205 |
206 {{(.+?)}} # interpolation
207 )
208 """,
209 re.VERBOSE,
210)
212ES_TEMPLATE_MATCH = re.compile(
213 r"""
214 \${ # start symbol
215 \s* # ignore whitespace
216 (([^}]+)) # variable name
217 \s* # ignore whitespace
218 } # end symbol
219 """,
220 re.VERBOSE,
221)
224PERCENT_MATCH = re.compile(r"(%([a-zA-Z0-9_]+)%)")
226VUE_MATCH = re.compile(
227 r"""
228 (
229 %?{([^}]+)}
230 |
231# See https://github.com/kazupon/vue-i18n/blob/44ff0b9/src/index.js#L30
232# but without case
233 (?:@(?:\.[a-z]+)?:(?:[\w\-_|./]+|\([\w\-_:|./]+\)))
234 )
235 """,
236 re.IGNORECASE | re.VERBOSE,
237)
239WHITESPACE = re.compile(r"\s+")
241# See https://github.com/Automattic/wp-calypso/blob/899d4cba090893f5a62012a08a6d0a4e8d028d98/packages/interpolate-components/src/tokenize.js#L30
242AUTOMATTIC_COMPONENTS_MATCH = re.compile(r"(\{\{/?\s*\w+\s*/?}})")
245def c_format_is_position_based(string: str):
246 return "$" not in string and string != "%"
249def pascal_format_is_position_based(string: str):
250 return ":" not in string and string != "%"
253def scheme_format_is_position_based(string: str):
254 return "@*" not in string and string != "~"
257def python_format_is_position_based(string: str):
258 return "(" not in string and string not in {"{", "}"}
261def name_format_is_position_based(string: str) -> bool: # noqa: FURB118
262 return not string
265def format_not_position_based(string: str) -> bool:
266 return False
269def extract_string_simple(match: re.Match) -> str:
270 return match.group(1)
273def extract_string_python_brace(match: re.Match) -> str:
274 # 1 and 2 are escaped braces and 3 is the actual match of format string
275 return match.group(1) or match.group(2) or match.group(3)
278FLAG_RULES: dict[
279 str,
280 tuple[
281 re.Pattern,
282 Callable[[str], bool],
283 Callable[[re.Match], str],
284 ],
285] = {
286 "python-format": (
287 PYTHON_PRINTF_MATCH,
288 python_format_is_position_based,
289 extract_string_simple,
290 ),
291 "php-format": (
292 PHP_PRINTF_MATCH,
293 c_format_is_position_based,
294 extract_string_simple,
295 ),
296 "c-format": (
297 C_PRINTF_MATCH,
298 c_format_is_position_based,
299 extract_string_simple,
300 ),
301 "object-pascal-format": (
302 PASCAL_FORMAT_MATCH,
303 pascal_format_is_position_based,
304 extract_string_simple,
305 ),
306 "perl-format": (C_PRINTF_MATCH, c_format_is_position_based, extract_string_simple),
307 "perl-brace-format": (
308 PERL_BRACE_MATCH,
309 name_format_is_position_based,
310 extract_string_simple,
311 ),
312 "javascript-format": (
313 C_PRINTF_MATCH,
314 c_format_is_position_based,
315 extract_string_simple,
316 ),
317 "lua-format": (
318 C_PRINTF_MATCH,
319 c_format_is_position_based,
320 extract_string_simple,
321 ),
322 "python-brace-format": (
323 PYTHON_BRACE_MATCH,
324 name_format_is_position_based,
325 extract_string_python_brace,
326 ),
327 "scheme-format": (
328 SCHEME_PRINTF_MATCH,
329 scheme_format_is_position_based,
330 extract_string_simple,
331 ),
332 "c-sharp-format": (
333 C_SHARP_MATCH,
334 name_format_is_position_based,
335 extract_string_simple,
336 ),
337 "java-printf-format": (
338 JAVA_MATCH,
339 c_format_is_position_based,
340 extract_string_simple,
341 ),
342 "automattic-components-format": (
343 AUTOMATTIC_COMPONENTS_MATCH,
344 format_not_position_based,
345 extract_string_simple,
346 ),
347}
350class BaseFormatCheck(TargetCheck):
351 """Base class for format string checks."""
353 regexp: Pattern[str] | None = None
354 plural_parameter_regexp: Pattern[str] | None = None
355 default_disabled = True
356 normalize_remove: ClassVar[set[str]] = set()
358 def check_target_unit(self, sources: list[str], targets: list[str], unit: Unit):
359 """Check single unit, handling plurals."""
360 return any(self.check_generator(sources, targets, unit))
362 def check_generator(
363 self, sources: list[str], targets: list[str], unit: Unit
364 ) -> Iterable[Literal[False] | MissingExtraDict]:
365 # Special case languages with single plural form
366 if len(sources) > 1 and len(targets) == 1:
367 yield self.check_format(sources[1], targets[0], False, unit)
368 return
370 # Use plural as source in case singular misses format string and plural has it
371 if (
372 len(sources) > 1
373 and not self.extract_matches(sources[0])
374 and self.extract_matches(sources[1])
375 ):
376 source = sources[1]
377 else:
378 source = sources[0]
380 # Fetch plural examples
381 plural_examples = SimpleLazyObject(lambda: unit.translation.plural.examples)
383 # Check singular
384 yield self.check_format(
385 source,
386 targets[0],
387 # Allow to skip format string in case there is single plural or in special
388 # case of 0, 1 plural. It is technically wrong, but in many cases there
389 # won't be 0 so don't trigger too many false positives.
390 # Some formats do strict linting here, so be strict on those as well.
391 len(sources) > 1
392 and "strict-format" not in unit.all_flags
393 and (
394 len(plural_examples[0]) == 1
395 or (
396 plural_examples[0] == ["0", "1"]
397 and not unit.translation.component.file_format_cls.strict_format_plurals
398 )
399 ),
400 unit,
401 )
403 # Do we have more to check?
404 if len(sources) == 1:
405 return
407 # Check plurals against plural from source
408 for i, target in enumerate(targets[1:]):
409 yield self.check_format(
410 sources[1], target, len(plural_examples[i + 1]) == 1, unit
411 )
413 def cleanup_string(self, text):
414 return text
416 def normalize(self, matches: list[str]) -> list[str]:
417 if not self.normalize_remove:
418 return matches
419 return [m for m in matches if m not in self.normalize_remove]
421 def extract_string(self, match: re.Match) -> str:
422 return extract_string_simple(match)
424 def extract_matches(self, string: str) -> list[str]:
425 if self.regexp is None:
426 return []
427 return [
428 self.cleanup_string(self.extract_string(match))
429 for match in self.regexp.finditer(string)
430 ]
432 def check_format(
433 self, source: str, target: str, ignore_missing: bool, unit: Unit
434 ) -> Literal[False] | MissingExtraDict:
435 """Check for format strings."""
436 if not target or not source:
437 return False
439 uses_position = True
441 # Calculate value and ignore mismatch in percent position
442 src_matches = self.normalize(self.extract_matches(source))
443 if src_matches:
444 uses_position = any(self.is_position_based(x) for x in src_matches)
446 tgt_matches = self.normalize(self.extract_matches(target))
448 missing: list[str] = []
449 extra: list[str] = []
450 if not uses_position:
451 src_counter = Counter(src_matches)
452 tgt_counter = Counter(tgt_matches)
454 if src_counter != tgt_counter:
455 missing = sorted(src_counter - tgt_counter)
456 extra = sorted(tgt_counter - src_counter)
457 elif src_matches != tgt_matches:
458 for i in range(min(len(src_matches), len(tgt_matches))):
459 if src_matches[i] != tgt_matches[i]:
460 missing.append(src_matches[i])
461 extra.append(tgt_matches[i])
462 missing.extend(src_matches[len(tgt_matches) :])
463 extra.extend(tgt_matches[len(src_matches) :])
465 # We can ignore missing format strings for first of plurals
466 if ignore_missing and missing and not extra:
467 return False
468 if missing or extra:
469 return {"missing": missing, "extra": extra}
470 return False
472 def is_position_based(self, string: str) -> bool:
473 return False
475 def check_single(self, source: str, target: str, unit: Unit) -> bool:
476 """Target strings are checked in check_target_unit."""
477 return False
479 def check_highlight(self, source: str, unit: Unit):
480 if self.should_skip(unit): 480 ↛ 482line 480 didn't jump to line 482 because the condition on line 480 was always true
481 return
482 if self.regexp is None:
483 return
484 match_objects = self.regexp.finditer(source)
485 for match in match_objects:
486 yield match.start(), match.end(), match.group()
488 def format_result(self, result: MissingExtraDict) -> Iterable[StrOrPromise]:
489 if (
490 result["missing"]
491 and all(self.is_position_based(flag) for flag in result["missing"])
492 and set(result["missing"]) == set(result["extra"])
493 ):
494 yield gettext(
495 "The following format strings are in the wrong order: %s"
496 ) % format_html_join_comma(
497 "{}",
498 list_to_tuples(
499 self.format_string(x) for x in sorted(set(result["missing"]))
500 ),
501 )
502 else:
503 yield from super().format_result(result)
505 def get_description(self, check_obj: Check) -> StrOrPromise:
506 unit = check_obj.unit
507 checks = self.check_generator(
508 unit.get_source_plurals(), unit.get_target_plurals(), unit
509 )
510 errors: list[StrOrPromise] = []
512 # Merge plurals
513 results: MissingExtraDict = defaultdict(list)
514 for result in checks:
515 if result:
516 for key, value in result.items():
517 results[key].extend(value)
518 if results:
519 errors.extend(self.format_result(results))
520 if errors:
521 return format_html_join(
522 mark_safe("<br />"),
523 "{}",
524 ((error,) for error in errors),
525 )
526 return super().get_description(check_obj)
528 def interpolate_number(self, text: str, number: int) -> str:
529 """
530 Interpolates a count in the format strings.
532 Attempt to find, in `text`, the placeholder for the number that controls
533 which plural form is used, and replace it with `number`.
535 Returns an empty string if the interpolation fails for any reason.
536 """
537 if not self.plural_parameter_regexp:
538 # Interpolation isn't available for this format.
539 msg = "Unsupported interpolation!"
540 raise ValueError(msg)
541 it = self.plural_parameter_regexp.finditer(text)
542 match = next(it, None)
543 if not match:
544 return text
545 if next(it, None):
546 # We've found two matching placeholders. We have no way to
547 # determine which one we should replace, so we give up.
548 return text
549 return text[: match.start()] + str(number) + text[match.end() :]
552class BasePrintfCheck(BaseFormatCheck):
553 """Base class for printf based format checks."""
555 normalize_remove: ClassVar[set[str]] = {"%"}
557 def __init__(self) -> None:
558 super().__init__()
559 self.regexp, self._is_position_based, self._extract_string = FLAG_RULES[
560 self.enable_string
561 ]
563 def is_position_based(self, string: str):
564 return self._is_position_based(string)
566 def extract_string(self, match: re.Match) -> str:
567 return self._extract_string(match)
569 def format_string(self, string: str) -> str:
570 return f"%{string}"
572 def cleanup_string(self, text):
573 """Remove locale-specific code from format string."""
574 if "'" in text:
575 return text.replace("'", "")
576 return text
579class PythonFormatCheck(BasePrintfCheck):
580 """Check for Python format string."""
582 check_id = "python_format"
583 name = gettext_lazy("Python format")
584 description = gettext_lazy("Python format string does not match source.")
585 plural_parameter_regexp = re.compile(r"%\((?:count|number|num|n)\)[a-zA-Z]")
588class PHPFormatCheck(BasePrintfCheck):
589 """Check for PHP format string."""
591 check_id = "php_format"
592 name = gettext_lazy("PHP format")
593 description = gettext_lazy("PHP format string does not match source.")
596class CFormatCheck(BasePrintfCheck):
597 """Check for C format string."""
599 check_id = "c_format"
600 name = gettext_lazy("C format")
601 description = gettext_lazy("C format string does not match source.")
604class PerlBraceFormatCheck(BaseFormatCheck):
605 """Check for Perl brace format string."""
607 check_id = "perl_brace_format"
608 name = gettext_lazy("Perl brace format")
609 description = gettext_lazy("Perl brace format string does not match source.")
610 regexp = PERL_BRACE_MATCH
611 plural_parameter_regexp = re.compile(r"\{(?:count|number|num|n)\}")
613 def is_position_based(self, string: str):
614 return name_format_is_position_based(string)
617class PerlFormatCheck(CFormatCheck):
618 """Check for Perl format string."""
620 check_id = "perl_format"
621 name = gettext_lazy("Perl format")
622 description = gettext_lazy("Perl format string does not match source.")
625class JavaScriptFormatCheck(CFormatCheck):
626 """Check for JavaScript format string."""
628 check_id = "javascript_format"
629 name = gettext_lazy("JavaScript format")
630 description = gettext_lazy("JavaScript format string does not match source.")
633class LuaFormatCheck(BasePrintfCheck):
634 """Check for Lua format string."""
636 check_id = "lua_format"
637 name = gettext_lazy("Lua format")
638 description = gettext_lazy("Lua format string does not match source.")
641class ObjectPascalFormatCheck(BasePrintfCheck):
642 """Check for Object Pascal format string."""
644 check_id = "object_pascal_format"
645 name = gettext_lazy("Object Pascal format")
646 description = gettext_lazy("Object Pascal format string does not match source.")
647 regexp = PASCAL_FORMAT_MATCH
650class SchemeFormatCheck(BasePrintfCheck):
651 """Check for Scheme format string."""
653 check_id = "scheme_format"
654 name = gettext_lazy("Scheme format")
655 description = gettext_lazy("Scheme format string does not match source.")
656 normalize_remove: ClassVar[set[str]] = {"~"}
658 def format_string(self, string: str) -> str:
659 return f"~{string}"
662class PythonBraceFormatCheck(BaseFormatCheck):
663 """Check for Python format string."""
665 check_id = "python_brace_format"
666 name = gettext_lazy("Python brace format")
667 description = gettext_lazy("Python brace format string does not match source.")
668 regexp = PYTHON_BRACE_MATCH
669 plural_parameter_regexp = re.compile(r"\{(?:count|number|num|n)\}")
670 normalize_remove: ClassVar[set[str]] = {"{", "}"}
672 def extract_string(self, match: re.Match) -> str:
673 return extract_string_python_brace(match)
675 def is_position_based(self, string: str):
676 return name_format_is_position_based(string)
678 def format_string(self, string: str) -> str:
679 return f"{{{string}}}"
681 def format_result(self, result: MissingExtraDict) -> Iterable[StrOrPromise]:
682 for char in ("{", "}"):
683 if char in result["extra"]:
684 result["extra"].remove(char)
685 yield format_html(
686 gettext("Single {} encountered in the format string."),
687 self.format_value(char),
688 )
689 yield from super().format_result(result)
691 def check_format(
692 self, source: str, target: str, ignore_missing: bool, unit: Unit
693 ) -> Literal[False] | MissingExtraDict:
694 result = super().check_format(source, target, ignore_missing, unit)
696 noformat = PYTHON_BRACE_MATCH.sub("", target)
698 add_extra: list[str] = [char for char in ("{", "}") if char in noformat]
700 if add_extra:
701 if isinstance(result, dict):
702 result["extra"].extend(add_extra)
703 else:
704 result = {"missing": [], "extra": add_extra}
706 return result
709class CSharpFormatCheck(BaseFormatCheck):
710 """Check for C# format string."""
712 check_id = "c_sharp_format"
713 name = gettext_lazy("C# format")
714 description = gettext_lazy("C# format string does not match source.")
715 regexp = C_SHARP_MATCH
716 extra_enable_strings = ("csharp-format",)
718 def is_position_based(self, string: str):
719 return name_format_is_position_based(string)
721 def format_string(self, string: str) -> str:
722 return f"{{{string}}}"
725class JavaFormatCheck(BasePrintfCheck):
726 """Check for Java format string."""
728 check_id = "java_printf_format"
729 name = gettext_lazy("Java format")
730 description = gettext_lazy("Java format string does not match source.")
733class JavaMessageFormatCheck(BaseFormatCheck):
734 """Check for Java MessageFormat string."""
736 check_id = "java_format"
737 name = gettext_lazy("Java MessageFormat")
738 description = gettext_lazy("Java MessageFormat string does not match source.")
739 regexp = JAVA_MESSAGE_MATCH
741 def format_string(self, string: str) -> str:
742 return f"{{{string}}}"
744 def should_skip(self, unit: Unit):
745 all_flags = unit.all_flags
746 if self.is_ignored(all_flags): 746 ↛ 747line 746 didn't jump to line 747 because the condition on line 746 was never true
747 return True
749 if "auto-java-messageformat" in unit.all_flags and "{0" in unit.source: 749 ↛ 750line 749 didn't jump to line 750 because the condition on line 749 was never true
750 return False
752 return super().should_skip(unit)
754 def check_format(
755 self, source: str, target: str, ignore_missing: bool, unit: Unit
756 ) -> Literal[False] | MissingExtraDict:
757 """Check for format strings."""
758 if not target or not source:
759 return False
761 result = super().check_format(source, target, ignore_missing, unit)
763 # Even number of quotes, unless in GWT which enforces this
764 if (
765 unit.translation.component.file_format != "gwt"
766 and target.count("'") % 2 != 0
767 ):
768 if not result:
769 result = {"missing": [], "extra": []}
770 result["missing"].append("'")
772 return result
774 def format_result(self, result: MissingExtraDict) -> Iterable[StrOrPromise]:
775 if "'" in result["missing"]:
776 result["missing"].remove("'")
777 yield gettext("You need to pair up an apostrophe with another one.")
778 yield from super().format_result(result)
781class I18NextInterpolationCheck(BaseFormatCheck):
782 check_id = "i18next_interpolation"
783 name = gettext_lazy("i18next interpolation")
784 description = gettext_lazy("The i18next interpolation does not match source.")
785 regexp = I18NEXT_MATCH
786 # https://www.i18next.com/translation-function/plurals
787 plural_parameter_regexp = re.compile(r"{{count}}")
789 def cleanup_string(self, text):
790 return WHITESPACE.sub("", text)
793class ESTemplateLiteralsCheck(BaseFormatCheck):
794 """Check for ES template literals."""
796 check_id = "es_format"
797 name = gettext_lazy("ECMAScript template literals")
798 description = gettext_lazy("ECMAScript template literals do not match source.")
799 regexp = ES_TEMPLATE_MATCH
800 plural_parameter_regexp = re.compile(r"\$\{(?:count|number|num|n)\}")
802 def cleanup_string(self, text):
803 return WHITESPACE.sub("", text)
805 def format_string(self, string: str) -> str:
806 return f"${{{string}}}"
809class PercentPlaceholdersCheck(BaseFormatCheck):
810 check_id = "percent_placeholders"
811 name = gettext_lazy("Percent placeholders")
812 description = gettext_lazy("The percent placeholders do not match source.")
813 regexp = PERCENT_MATCH
814 plural_parameter_regexp = re.compile(r"%(?:count|number|num|n)%")
817class VueFormattingCheck(BaseFormatCheck):
818 check_id = "vue_format"
819 name = gettext_lazy("Vue I18n formatting")
820 description = gettext_lazy("The Vue I18n formatting does not match source.")
821 regexp = VUE_MATCH
822 # https://kazupon.github.io/vue-i18n/guide/pluralization.html
823 plural_parameter_regexp = re.compile(r"%?\{(?:count|n)\}")
826class AutomatticComponentsCheck(BaseFormatCheck):
827 check_id = "automattic_components_format"
828 name = gettext_lazy("Automattic components formatting")
829 description = gettext_lazy(
830 "The Automattic components' placeholders do not match the source."
831 )
833 def __init__(self) -> None:
834 super().__init__()
835 self.regexp, self._is_position_based, self._extract_string = FLAG_RULES[
836 self.enable_string
837 ]
839 def is_position_based(self, string: str) -> bool:
840 return self._is_position_based(string)
842 def extract_string(self, match: re.Match) -> str:
843 return self._extract_string(match)
846class MultipleUnnamedFormatsCheck(SourceCheck):
847 check_id = "unnamed_format"
848 name = gettext_lazy("Multiple unnamed variables")
849 description = gettext_lazy(
850 "There are multiple unnamed variables in the string, "
851 "making it impossible for translators to reorder them."
852 )
854 def check_source_unit(self, sources: list[str], unit: Unit) -> bool:
855 """Check source string."""
856 rules = [FLAG_RULES[flag] for flag in unit.all_flags if flag in FLAG_RULES]
857 if not rules: 857 ↛ 859line 857 didn't jump to line 859 because the condition on line 857 was always true
858 return False
859 found = set()
860 for regexp, is_position_based, extract_string in rules:
861 for match in regexp.finditer(sources[0]):
862 if is_position_based(extract_string(match)):
863 found.add((match.start(0), match.end(0)))
864 if len(found) >= 2:
865 return True
866 return False