|
| 1 | +#!/usr/bin/env python3 |
| 2 | +# |
| 3 | +############################################################################## |
| 4 | +# Copyright (c) 2026 |
| 5 | +# |
| 6 | +# Author(s): |
| 7 | +# ChatGPT |
| 8 | +# ann0see |
| 9 | +# The Jamulus Development Team |
| 10 | +# |
| 11 | +############################################################################## |
| 12 | +# |
| 13 | +# This program is free software; you can redistribute it and/or modify it under |
| 14 | +# the terms of the GNU General Public License as published by the Free Software |
| 15 | +# Foundation; either version 2 of the License, or (at your option) any later |
| 16 | +# version. |
| 17 | +# |
| 18 | +# This program is distributed in the hope that it will be useful, but WITHOUT |
| 19 | +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS |
| 20 | +# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more |
| 21 | +# details. |
| 22 | +# |
| 23 | +# You should have received a copy of the GNU General Public License along with |
| 24 | +# this program; if not, write to the Free Software Foundation, Inc., |
| 25 | +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA |
| 26 | +# |
| 27 | +############################################################################## |
| 28 | + |
| 29 | +""" |
| 30 | +Qt TS translation checker. |
| 31 | +
|
| 32 | +This tool validates Qt `.ts` translation files according to Qt Linguist |
| 33 | +semantics. |
| 34 | +Warnings are reported with best-effort line numbers. In strict mode, the |
| 35 | +presence of any warning results in a non-zero exit code to allow CI failure. |
| 36 | +""" |
| 37 | + |
| 38 | +import argparse |
| 39 | +import re |
| 40 | +import sys |
| 41 | +import xml.etree.ElementTree as ET |
| 42 | +from collections import defaultdict |
| 43 | +from dataclasses import dataclass |
| 44 | +from enum import IntEnum |
| 45 | +from pathlib import Path |
| 46 | + |
| 47 | +# Qt-style placeholders |
| 48 | +PLACEHOLDER_RE = re.compile(r"%\d+") |
| 49 | +HTML_TAG_RE = re.compile(r"<[^>]+>") |
| 50 | + |
| 51 | +# ANSI escape codes |
| 52 | +BOLD = "\033[1m" |
| 53 | +CYAN = "\033[36m" |
| 54 | +YELLOW = "\033[33m" |
| 55 | +RED = "\033[31m" |
| 56 | +RESET = "\033[0m" |
| 57 | + |
| 58 | +# ---------- Severity Enum ---------- |
| 59 | + |
| 60 | +class Severity(IntEnum): |
| 61 | + WARNING = 1 |
| 62 | + SEVERE = 2 |
| 63 | + |
| 64 | +# ---------- helpers ---------- |
| 65 | + |
| 66 | +def approximate_message_lines(text: str): |
| 67 | + """Yield approximate line numbers for <message> elements.""" |
| 68 | + lines = text.splitlines() |
| 69 | + cursor = 0 |
| 70 | + for _ in range(text.count("<message")): |
| 71 | + for i in range(cursor, len(lines)): |
| 72 | + if "<message" in lines[i]: |
| 73 | + cursor = i + 1 |
| 74 | + yield i + 1 |
| 75 | + break |
| 76 | + else: |
| 77 | + yield 0 |
| 78 | + |
| 79 | +# ---------- data structures ---------- |
| 80 | + |
| 81 | +@dataclass(frozen=True) |
| 82 | +class MessageContext: |
| 83 | + ts_file: Path |
| 84 | + line: int |
| 85 | + lang: str |
| 86 | + source: str |
| 87 | + translation: str |
| 88 | + tr_type: str |
| 89 | + excerpt: str |
| 90 | + |
| 91 | +@dataclass(frozen=True) |
| 92 | +class WarningItem: |
| 93 | + ts_file: Path |
| 94 | + line: int |
| 95 | + message: str |
| 96 | + severity: Severity |
| 97 | + |
| 98 | +# ---------- checks ---------- |
| 99 | + |
| 100 | +def check_language_header(ts_file: Path, root): |
| 101 | + """Header mismatch is a warning (not severe).""" |
| 102 | + file_lang = ts_file.stem.replace("translation_", "") |
| 103 | + header_lang = root.attrib.get("language", "") |
| 104 | + if header_lang != file_lang: |
| 105 | + return [WarningItem(ts_file, 0, |
| 106 | + f"Language header mismatch '{header_lang}' != '{file_lang}'", |
| 107 | + Severity.WARNING)] |
| 108 | + return [] |
| 109 | + |
| 110 | +def check_empty_translation(ctx: MessageContext): |
| 111 | + """Empty translation is SEVERE.""" |
| 112 | + if not ctx.translation and ctx.tr_type != "unfinished": |
| 113 | + return [WarningItem(ctx.ts_file, ctx.line, |
| 114 | + f"{ctx.lang}: empty translation for '{ctx.excerpt}...'", |
| 115 | + Severity.SEVERE)] |
| 116 | + return [] |
| 117 | + |
| 118 | +def check_placeholders(ctx: MessageContext): |
| 119 | + """Placeholder mismatch is a WARNING.""" |
| 120 | + if set(PLACEHOLDER_RE.findall(ctx.source)) != set(PLACEHOLDER_RE.findall(ctx.translation)): |
| 121 | + return [WarningItem(ctx.ts_file, ctx.line, |
| 122 | + f"{ctx.lang}: placeholder mismatch for '{ctx.excerpt}...'\n" |
| 123 | + f"Source: {ctx.source}\nTranslation: {ctx.translation}", |
| 124 | + Severity.WARNING)] |
| 125 | + return [] |
| 126 | + |
| 127 | +def check_html(ctx: MessageContext): |
| 128 | + """HTML missing in translation is a WARNING.""" |
| 129 | + if HTML_TAG_RE.search(ctx.source) and not HTML_TAG_RE.search(ctx.translation) and ctx.tr_type != "unfinished": |
| 130 | + return [WarningItem(ctx.ts_file, ctx.line, |
| 131 | + f"{ctx.lang}: HTML missing for '{ctx.excerpt}...'\n" |
| 132 | + f"Source: {ctx.source}\nTranslation: {ctx.translation}", |
| 133 | + Severity.WARNING)] |
| 134 | + return [] |
| 135 | + |
| 136 | +# ---------- test runner ---------- |
| 137 | + |
| 138 | +def detect_warnings(ts_file: Path): |
| 139 | + """Run all checks and return list of WarningItem.""" |
| 140 | + try: |
| 141 | + text = ts_file.read_text(encoding="utf-8") |
| 142 | + root = ET.fromstring(text) |
| 143 | + except (OSError, ET.ParseError) as exc: |
| 144 | + return [WarningItem(ts_file, 0, |
| 145 | + f"Error reading or parsing XML: {exc}", |
| 146 | + Severity.SEVERE)] |
| 147 | + |
| 148 | + warnings = [] |
| 149 | + warnings.extend(check_language_header(ts_file, root)) |
| 150 | + |
| 151 | + file_lang = ts_file.stem.replace("translation_", "") |
| 152 | + message_lines = approximate_message_lines(text) |
| 153 | + |
| 154 | + for context in root.findall("context"): |
| 155 | + for message, line in zip(context.findall("message"), message_lines): |
| 156 | + source = (message.findtext("source") or "").strip() |
| 157 | + tr_elem = message.find("translation") |
| 158 | + translation = "" |
| 159 | + tr_type = "" |
| 160 | + if tr_elem is not None: |
| 161 | + translation = (tr_elem.text or "").strip() |
| 162 | + tr_type = tr_elem.attrib.get("type", "") |
| 163 | + excerpt = source[:30].replace("\n", " ") |
| 164 | + |
| 165 | + ctx = MessageContext(ts_file, line, file_lang, source, translation, tr_type, excerpt) |
| 166 | + warnings.extend(check_empty_translation(ctx)) |
| 167 | + warnings.extend(check_placeholders(ctx)) |
| 168 | + warnings.extend(check_html(ctx)) |
| 169 | + return warnings |
| 170 | + |
| 171 | +# ---------- CLI harness ---------- |
| 172 | + |
| 173 | +def main(): |
| 174 | + parser = argparse.ArgumentParser(description="Qt TS translation checker with severity") |
| 175 | + parser.add_argument("--ts-dir", type=Path, default=Path("src/translation"), |
| 176 | + help="Directory containing translation_*.ts files") |
| 177 | + parser.add_argument("--strict", action="store_true", |
| 178 | + help="Exit non-zero if any warning is found") |
| 179 | + args = parser.parse_args() |
| 180 | + |
| 181 | + if not args.ts_dir.exists(): |
| 182 | + print(f"Directory not found: {args.ts_dir}", file=sys.stderr) |
| 183 | + return 2 |
| 184 | + ts_files = sorted(args.ts_dir.glob("translation_*.ts")) |
| 185 | + if not ts_files: |
| 186 | + print(f"No TS files found in {args.ts_dir}", file=sys.stderr) |
| 187 | + return 2 |
| 188 | + |
| 189 | + all_warnings = [] |
| 190 | + for ts_file in ts_files: |
| 191 | + all_warnings.extend(detect_warnings(ts_file)) |
| 192 | + |
| 193 | + # Group warnings for output |
| 194 | + grouped = defaultdict(list) |
| 195 | + for w in all_warnings: |
| 196 | + grouped[(w.ts_file, w.line)].append(w) |
| 197 | + |
| 198 | + # --- detailed output --- |
| 199 | + for (file, line), messages in sorted(grouped.items()): |
| 200 | + for w in messages: |
| 201 | + color = RED if w.severity == Severity.SEVERE else YELLOW |
| 202 | + print(f"{BOLD}{file}{RESET} {CYAN}line {line}{RESET}: {color}{w.message}{RESET}") |
| 203 | + |
| 204 | + # --- test suite style summary --- |
| 205 | + failures_by_language = defaultdict(lambda: {"severe":0, "warning":0}) |
| 206 | + all_languages = set() |
| 207 | + has_severe = False |
| 208 | + |
| 209 | + for w in all_warnings: |
| 210 | + lang = w.ts_file.stem.replace("translation_", "") |
| 211 | + all_languages.add(lang) |
| 212 | + if w.severity == Severity.SEVERE: |
| 213 | + failures_by_language[lang]["severe"] += 1 |
| 214 | + has_severe = True |
| 215 | + else: |
| 216 | + failures_by_language[lang]["warning"] += 1 |
| 217 | + |
| 218 | + print("\n== Test Summary ==") |
| 219 | + for lang in sorted(all_languages): |
| 220 | + counts = failures_by_language[lang] |
| 221 | + print(f"{BOLD}[{lang}]{RESET} Severe: {counts['severe']}, Warnings: {counts['warning']}") |
| 222 | + |
| 223 | + total_severe = sum(f["severe"] for f in failures_by_language.values()) |
| 224 | + total_warning = sum(f["warning"] for f in failures_by_language.values()) |
| 225 | + print(f"\nTotal Severe: {total_severe}, Total Warnings: {total_warning}") |
| 226 | + |
| 227 | + # Exit on severe errors or strict mode |
| 228 | + if has_severe or (args.strict and total_warning > 0): |
| 229 | + return 1 |
| 230 | + |
| 231 | + return 0 |
| 232 | + |
| 233 | +if __name__ == "__main__": |
| 234 | + sys.exit(main()) |
| 235 | + |
0 commit comments