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

1# Copyright © Michal Čihař <michal@weblate.org> 

2# 

3# SPDX-License-Identifier: GPL-3.0-or-later 

4 

5from __future__ import annotations 

6 

7import re 

8from collections import Counter, defaultdict 

9from typing import TYPE_CHECKING, ClassVar, Literal 

10 

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 

15 

16from weblate.checks.base import SourceCheck, TargetCheck 

17from weblate.utils.html import format_html_join_comma, list_to_tuples 

18 

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 

22 

23 from django_stubs_ext import StrOrPromise 

24 

25 from weblate.checks.base import MissingExtraDict 

26 from weblate.trans.models import Unit 

27 

28 from .models import Check 

29 

30 

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) 

45 

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) 

63 

64 

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) 

79 

80 

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) 

95 

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) 

111 

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 

125 

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) 

146 

147PERL_BRACE_MATCH = re.compile(r"({([a-zA-Z0-9_]+)})") 

148 

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) 

169 

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) 

185 

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) 

200 

201I18NEXT_MATCH = re.compile( 

202 r""" 

203 ( 

204 \$t\((.+?)\) # nesting 

205 | 

206 {{(.+?)}} # interpolation 

207 ) 

208 """, 

209 re.VERBOSE, 

210) 

211 

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) 

222 

223 

224PERCENT_MATCH = re.compile(r"(%([a-zA-Z0-9_]+)%)") 

225 

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) 

238 

239WHITESPACE = re.compile(r"\s+") 

240 

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*/?}})") 

243 

244 

245def c_format_is_position_based(string: str): 

246 return "$" not in string and string != "%" 

247 

248 

249def pascal_format_is_position_based(string: str): 

250 return ":" not in string and string != "%" 

251 

252 

253def scheme_format_is_position_based(string: str): 

254 return "@*" not in string and string != "~" 

255 

256 

257def python_format_is_position_based(string: str): 

258 return "(" not in string and string not in {"{", "}"} 

259 

260 

261def name_format_is_position_based(string: str) -> bool: # noqa: FURB118 

262 return not string 

263 

264 

265def format_not_position_based(string: str) -> bool: 

266 return False 

267 

268 

269def extract_string_simple(match: re.Match) -> str: 

270 return match.group(1) 

271 

272 

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) 

276 

277 

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} 

348 

349 

350class BaseFormatCheck(TargetCheck): 

351 """Base class for format string checks.""" 

352 

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() 

357 

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)) 

361 

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 

369 

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] 

379 

380 # Fetch plural examples 

381 plural_examples = SimpleLazyObject(lambda: unit.translation.plural.examples) 

382 

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 ) 

402 

403 # Do we have more to check? 

404 if len(sources) == 1: 

405 return 

406 

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 ) 

412 

413 def cleanup_string(self, text): 

414 return text 

415 

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] 

420 

421 def extract_string(self, match: re.Match) -> str: 

422 return extract_string_simple(match) 

423 

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 ] 

431 

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 

438 

439 uses_position = True 

440 

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) 

445 

446 tgt_matches = self.normalize(self.extract_matches(target)) 

447 

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) 

453 

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) :]) 

464 

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 

471 

472 def is_position_based(self, string: str) -> bool: 

473 return False 

474 

475 def check_single(self, source: str, target: str, unit: Unit) -> bool: 

476 """Target strings are checked in check_target_unit.""" 

477 return False 

478 

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() 

487 

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) 

504 

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] = [] 

511 

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) 

527 

528 def interpolate_number(self, text: str, number: int) -> str: 

529 """ 

530 Interpolates a count in the format strings. 

531 

532 Attempt to find, in `text`, the placeholder for the number that controls 

533 which plural form is used, and replace it with `number`. 

534 

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() :] 

550 

551 

552class BasePrintfCheck(BaseFormatCheck): 

553 """Base class for printf based format checks.""" 

554 

555 normalize_remove: ClassVar[set[str]] = {"%"} 

556 

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 ] 

562 

563 def is_position_based(self, string: str): 

564 return self._is_position_based(string) 

565 

566 def extract_string(self, match: re.Match) -> str: 

567 return self._extract_string(match) 

568 

569 def format_string(self, string: str) -> str: 

570 return f"%{string}" 

571 

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 

577 

578 

579class PythonFormatCheck(BasePrintfCheck): 

580 """Check for Python format string.""" 

581 

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]") 

586 

587 

588class PHPFormatCheck(BasePrintfCheck): 

589 """Check for PHP format string.""" 

590 

591 check_id = "php_format" 

592 name = gettext_lazy("PHP format") 

593 description = gettext_lazy("PHP format string does not match source.") 

594 

595 

596class CFormatCheck(BasePrintfCheck): 

597 """Check for C format string.""" 

598 

599 check_id = "c_format" 

600 name = gettext_lazy("C format") 

601 description = gettext_lazy("C format string does not match source.") 

602 

603 

604class PerlBraceFormatCheck(BaseFormatCheck): 

605 """Check for Perl brace format string.""" 

606 

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)\}") 

612 

613 def is_position_based(self, string: str): 

614 return name_format_is_position_based(string) 

615 

616 

617class PerlFormatCheck(CFormatCheck): 

618 """Check for Perl format string.""" 

619 

620 check_id = "perl_format" 

621 name = gettext_lazy("Perl format") 

622 description = gettext_lazy("Perl format string does not match source.") 

623 

624 

625class JavaScriptFormatCheck(CFormatCheck): 

626 """Check for JavaScript format string.""" 

627 

628 check_id = "javascript_format" 

629 name = gettext_lazy("JavaScript format") 

630 description = gettext_lazy("JavaScript format string does not match source.") 

631 

632 

633class LuaFormatCheck(BasePrintfCheck): 

634 """Check for Lua format string.""" 

635 

636 check_id = "lua_format" 

637 name = gettext_lazy("Lua format") 

638 description = gettext_lazy("Lua format string does not match source.") 

639 

640 

641class ObjectPascalFormatCheck(BasePrintfCheck): 

642 """Check for Object Pascal format string.""" 

643 

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 

648 

649 

650class SchemeFormatCheck(BasePrintfCheck): 

651 """Check for Scheme format string.""" 

652 

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]] = {"~"} 

657 

658 def format_string(self, string: str) -> str: 

659 return f"~{string}" 

660 

661 

662class PythonBraceFormatCheck(BaseFormatCheck): 

663 """Check for Python format string.""" 

664 

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]] = {"{", "}"} 

671 

672 def extract_string(self, match: re.Match) -> str: 

673 return extract_string_python_brace(match) 

674 

675 def is_position_based(self, string: str): 

676 return name_format_is_position_based(string) 

677 

678 def format_string(self, string: str) -> str: 

679 return f"{{{string}}}" 

680 

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) 

690 

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) 

695 

696 noformat = PYTHON_BRACE_MATCH.sub("", target) 

697 

698 add_extra: list[str] = [char for char in ("{", "}") if char in noformat] 

699 

700 if add_extra: 

701 if isinstance(result, dict): 

702 result["extra"].extend(add_extra) 

703 else: 

704 result = {"missing": [], "extra": add_extra} 

705 

706 return result 

707 

708 

709class CSharpFormatCheck(BaseFormatCheck): 

710 """Check for C# format string.""" 

711 

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",) 

717 

718 def is_position_based(self, string: str): 

719 return name_format_is_position_based(string) 

720 

721 def format_string(self, string: str) -> str: 

722 return f"{{{string}}}" 

723 

724 

725class JavaFormatCheck(BasePrintfCheck): 

726 """Check for Java format string.""" 

727 

728 check_id = "java_printf_format" 

729 name = gettext_lazy("Java format") 

730 description = gettext_lazy("Java format string does not match source.") 

731 

732 

733class JavaMessageFormatCheck(BaseFormatCheck): 

734 """Check for Java MessageFormat string.""" 

735 

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 

740 

741 def format_string(self, string: str) -> str: 

742 return f"{{{string}}}" 

743 

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 

748 

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 

751 

752 return super().should_skip(unit) 

753 

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 

760 

761 result = super().check_format(source, target, ignore_missing, unit) 

762 

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("'") 

771 

772 return result 

773 

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) 

779 

780 

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}}") 

788 

789 def cleanup_string(self, text): 

790 return WHITESPACE.sub("", text) 

791 

792 

793class ESTemplateLiteralsCheck(BaseFormatCheck): 

794 """Check for ES template literals.""" 

795 

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)\}") 

801 

802 def cleanup_string(self, text): 

803 return WHITESPACE.sub("", text) 

804 

805 def format_string(self, string: str) -> str: 

806 return f"${{{string}}}" 

807 

808 

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)%") 

815 

816 

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)\}") 

824 

825 

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 ) 

832 

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 ] 

838 

839 def is_position_based(self, string: str) -> bool: 

840 return self._is_position_based(string) 

841 

842 def extract_string(self, match: re.Match) -> str: 

843 return self._extract_string(match) 

844 

845 

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 ) 

853 

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