|
6 | 6 |
|
7 | 7 | import re |
8 | 8 | import threading |
9 | | -from collections import Counter, defaultdict |
| 9 | +from collections import Counter |
10 | 10 | from functools import cache, lru_cache |
11 | 11 | from itertools import chain |
12 | 12 | from types import SimpleNamespace |
|
16 | 16 | from django.core.exceptions import ValidationError |
17 | 17 | from django.core.validators import URLValidator |
18 | 18 | from django.utils.functional import cached_property |
19 | | -from django.utils.html import format_html_join |
20 | | -from django.utils.safestring import mark_safe |
21 | 19 | from django.utils.translation import gettext, gettext_lazy |
22 | 20 | from docutils import utils |
23 | 21 | from docutils.core import Publisher |
|
39 | 37 | from docutils.readers.standalone import Reader |
40 | 38 | from docutils.writers.null import Writer |
41 | 39 |
|
42 | | -from weblate.checks.base import Highlight, TargetCheck |
| 40 | +from weblate.checks.base import Highlight, PluralResultDescriptionMixin, TargetCheck |
43 | 41 | from weblate.checks.format import ( |
44 | 42 | ES_TEMPLATE_MATCH, |
45 | 43 | FLAG_RULES, |
|
73 | 71 | from weblate.trans.models import Unit |
74 | 72 |
|
75 | 73 | from .base import FixupType |
76 | | - from .models import Check |
77 | 74 |
|
78 | 75 | DOCUTILS_PARSER_LOCK = threading.Lock() |
79 | 76 | BBCODE_MATCH = re.compile( |
@@ -902,7 +899,7 @@ def extract_rst_references( |
902 | 899 | return dict(result), Counter(item[0] for item in result), tuple(highlights) |
903 | 900 |
|
904 | 901 |
|
905 | | -class RSTReferencesCheck(RSTBaseCheck): |
| 902 | +class RSTReferencesCheck(PluralResultDescriptionMixin, RSTBaseCheck): |
906 | 903 | check_id = "rst-references" |
907 | 904 | name = gettext_lazy("Inconsistent reStructuredText") |
908 | 905 | description = gettext_lazy( |
@@ -947,29 +944,6 @@ def check_single( |
947 | 944 | } |
948 | 945 | return False |
949 | 946 |
|
950 | | - def get_description(self, check_obj: Check) -> StrOrPromise: |
951 | | - unit = check_obj.unit |
952 | | - |
953 | | - errors: list[StrOrPromise] = [] |
954 | | - results: MissingExtraDict = cast("MissingExtraDict", defaultdict(list)) |
955 | | - |
956 | | - # Merge plurals |
957 | | - for result in self.check_target_generator( |
958 | | - unit.get_source_plurals(), unit.get_target_plurals(), unit |
959 | | - ): |
960 | | - if isinstance(result, dict): |
961 | | - for key, value in result.items(): |
962 | | - results[key].extend(value) |
963 | | - if results: |
964 | | - errors.extend(self.format_result(results)) |
965 | | - if errors: |
966 | | - return format_html_join( |
967 | | - mark_safe("<br />"), |
968 | | - "{}", |
969 | | - ((error,) for error in errors), |
970 | | - ) |
971 | | - return super().get_description(check_obj) |
972 | | - |
973 | 947 | def check_highlight(self, source: str, unit: Unit): |
974 | 948 | if self.should_skip(unit): |
975 | 949 | return |
@@ -1060,7 +1034,7 @@ def error_collector(data: system_message) -> None: |
1060 | 1034 | return tuple(errors), tuple(roles) |
1061 | 1035 |
|
1062 | 1036 |
|
1063 | | -class RSTSyntaxCheck(RSTBaseCheck): |
| 1037 | +class RSTSyntaxCheck(PluralResultDescriptionMixin, RSTBaseCheck): |
1064 | 1038 | check_id = "rst-syntax" |
1065 | 1039 | name = gettext_lazy("reStructuredText syntax error") |
1066 | 1040 | description = gettext_lazy("reStructuredText syntax error in the translation.") |
@@ -1092,25 +1066,122 @@ def check_single( |
1092 | 1066 | return {"errors": errors} |
1093 | 1067 | return False |
1094 | 1068 |
|
1095 | | - def get_description(self, check_obj: Check) -> StrOrPromise: |
1096 | | - unit = check_obj.unit |
1097 | 1069 |
|
1098 | | - errors: list[StrOrPromise] = [] |
1099 | | - results: MissingExtraDict = cast("MissingExtraDict", defaultdict(list)) |
| 1070 | +# inline (`name:target[attrs]`) and block (`name::target[attrs]`) macros. |
| 1071 | +ASCIIDOC_MACRO = re.compile( |
| 1072 | + r"(?P<name>[a-z][a-z0-9]*)(?P<sep>::?)(?P<target>[^\s\[]*)\[(?P<attrs>(?:\\.|[^\]])*)\]" |
| 1073 | +) |
| 1074 | +# cross references: <<id>> or <<id,text>>. |
| 1075 | +ASCIIDOC_XREF = re.compile(r"<<(?P<id>[^,>]+)(?:,(?P<text>[^>]*))?>>") |
| 1076 | +# inline anchors: [[id]] or [[id,label]]. |
| 1077 | +ASCIIDOC_INLINE_ANCHOR = re.compile(r"\[\[(?P<id>[^,\]]+)(?:,(?P<label>[^\]]*))?\]\]") |
| 1078 | +# passthroughs. Bare single-plus `+...+` is excluded to avoid false positives. |
| 1079 | +ASCIIDOC_PASSTHROUGH = re.compile( |
| 1080 | + r"\+\+\+.+?\+\+\+|\+\+.+?\+\+|`\+.+?\+`|\$\$.+?\$\$", |
| 1081 | + re.DOTALL, |
| 1082 | +) |
| 1083 | + |
1100 | 1084 |
|
1101 | | - # Merge plurals |
1102 | | - for result in self.check_target_generator( |
1103 | | - unit.get_source_plurals(), unit.get_target_plurals(), unit |
1104 | | - ): |
1105 | | - if isinstance(result, dict): |
1106 | | - for key, value in result.items(): |
1107 | | - results[key].extend(value) |
1108 | | - if results: |
1109 | | - errors.extend(self.format_result(results)) |
1110 | | - if errors: |
1111 | | - return format_html_join( |
1112 | | - mark_safe("<br />"), |
1113 | | - "{}", |
1114 | | - ((error,) for error in errors), |
| 1085 | +def is_preceded_by_asciidoc_escape(text: str, start: int) -> bool: |
| 1086 | + """Return whether markup at ``start`` is escaped and renders as literal text.""" |
| 1087 | + backslashes = 0 |
| 1088 | + pos = start - 1 |
| 1089 | + while pos >= 0 and text[pos] == "\\": |
| 1090 | + backslashes += 1 |
| 1091 | + pos -= 1 |
| 1092 | + return backslashes % 2 == 1 |
| 1093 | + |
| 1094 | + |
| 1095 | +class AsciiDocMarkupCheck(PluralResultDescriptionMixin, TargetCheck): |
| 1096 | + """Check that AsciiDoc macros, xrefs, and passthroughs match the source.""" |
| 1097 | + |
| 1098 | + check_id = "asciidoc-markup" |
| 1099 | + name = gettext_lazy("AsciiDoc markup") |
| 1100 | + description = gettext_lazy("AsciiDoc markup does not match source.") |
| 1101 | + version_added = "2026.8" |
| 1102 | + default_disabled = True |
| 1103 | + |
| 1104 | + def __init__(self) -> None: |
| 1105 | + super().__init__() |
| 1106 | + self.enable_string = "asciidoc-text" |
| 1107 | + |
| 1108 | + def get_missing_text(self, values: Iterable[str]) -> StrOrPromise: |
| 1109 | + return self.get_values_text( |
| 1110 | + gettext("The following AsciiDoc markup is missing: {}"), values |
| 1111 | + ) |
| 1112 | + |
| 1113 | + def get_extra_text(self, values: Iterable[str]) -> StrOrPromise: |
| 1114 | + return self.get_values_text( |
| 1115 | + gettext("The following AsciiDoc markup is extra: {}"), values |
| 1116 | + ) |
| 1117 | + |
| 1118 | + def check_single( |
| 1119 | + self, source: str, target: str, unit: Unit |
| 1120 | + ) -> bool | MissingExtraDict: |
| 1121 | + src_set = extract_asciidoc_markup(source) |
| 1122 | + tgt_set = extract_asciidoc_markup(target) |
| 1123 | + |
| 1124 | + missing = src_set - tgt_set |
| 1125 | + extra = tgt_set - src_set |
| 1126 | + |
| 1127 | + if missing or extra: |
| 1128 | + return { |
| 1129 | + "missing": list(missing.elements()), |
| 1130 | + "extra": list(extra.elements()), |
| 1131 | + "errors": [], |
| 1132 | + } |
| 1133 | + return False |
| 1134 | + |
| 1135 | + def check_highlight(self, source: str, unit: Unit): |
| 1136 | + if self.should_skip(unit): |
| 1137 | + return |
| 1138 | + yield from iter_asciidoc_highlights(source) |
| 1139 | + |
| 1140 | + |
| 1141 | +def extract_asciidoc_markup(text: str) -> Counter[str]: |
| 1142 | + tokens: list[str] = [] |
| 1143 | + for match in ASCIIDOC_MACRO.finditer(text): |
| 1144 | + if is_preceded_by_asciidoc_escape(text, match.start()): |
| 1145 | + continue |
| 1146 | + if match.group("name") == "pass": |
| 1147 | + # special case for passthrough macros, the content is not translatable and must be preserved |
| 1148 | + tokens.append(match.group()) |
| 1149 | + else: |
| 1150 | + tokens.append( |
| 1151 | + f"{match.group('name')}{match.group('sep')}{match.group('target')}[]" |
1115 | 1152 | ) |
1116 | | - return super().get_description(check_obj) |
| 1153 | + tokens.extend( |
| 1154 | + f"<<{match.group('id')}>>" |
| 1155 | + for match in ASCIIDOC_XREF.finditer(text) |
| 1156 | + if not is_preceded_by_asciidoc_escape(text, match.start()) |
| 1157 | + ) |
| 1158 | + tokens.extend( |
| 1159 | + f"[[{match.group('id')}]]" |
| 1160 | + for match in ASCIIDOC_INLINE_ANCHOR.finditer(text) |
| 1161 | + if not is_preceded_by_asciidoc_escape(text, match.start()) |
| 1162 | + ) |
| 1163 | + tokens.extend( |
| 1164 | + match.group() |
| 1165 | + for match in ASCIIDOC_PASSTHROUGH.finditer(text) |
| 1166 | + if not is_preceded_by_asciidoc_escape(text, match.start()) |
| 1167 | + ) |
| 1168 | + return Counter(tokens) |
| 1169 | + |
| 1170 | + |
| 1171 | +def iter_asciidoc_highlights(text: str) -> Iterable[Highlight]: |
| 1172 | + for match in ASCIIDOC_MACRO.finditer(text): |
| 1173 | + if is_preceded_by_asciidoc_escape(text, match.start()): |
| 1174 | + continue |
| 1175 | + yield Highlight(match.start(), match.end(), match.group(), kind="syntax") |
| 1176 | + for match in ASCIIDOC_XREF.finditer(text): |
| 1177 | + if is_preceded_by_asciidoc_escape(text, match.start()): |
| 1178 | + continue |
| 1179 | + yield Highlight(match.start(), match.end(), match.group(), kind="syntax") |
| 1180 | + for match in ASCIIDOC_INLINE_ANCHOR.finditer(text): |
| 1181 | + if is_preceded_by_asciidoc_escape(text, match.start()): |
| 1182 | + continue |
| 1183 | + yield Highlight(match.start(), match.end(), match.group(), kind="syntax") |
| 1184 | + for match in ASCIIDOC_PASSTHROUGH.finditer(text): |
| 1185 | + if is_preceded_by_asciidoc_escape(text, match.start()): |
| 1186 | + continue |
| 1187 | + yield Highlight(match.start(), match.end(), match.group(), kind="syntax") |
0 commit comments