3333
3434This tool validates Qt `.ts` translation files according to Qt Linguist
3535semantics.
36- Warnings are reported with best-effort line numbers. In strict mode, the
37- presence of any warning results in a non-zero exit code to allow CI failure.
3836"""
3937
4038import argparse
5149HTML_TAG_RE = re .compile (r"<[^>]+>" )
5250
5351# ANSI escape codes
54- BOLD = "\033 [1m"
55- CYAN = "\033 [36m"
56- YELLOW = "\033 [33m"
57- RED = "\033 [31m"
58- RESET = "\033 [0m"
52+ BOLD , CYAN , YELLOW , RED , RESET = "\033 [1m" , "\033 [36m" , "\033 [33m" , "\033 [31m" , "\033 [0m"
5953
6054
61- # Severity Enum
6255class Severity (IntEnum ):
6356 WARNING = 1
6457 SEVERE = 2
6558
6659
67- # Data structures
6860@dataclass (frozen = True )
6961class MessageContext :
7062 ts_file : Path
@@ -85,7 +77,6 @@ class WarningItem:
8577 severity : Severity
8678
8779
88- # Helpers
8980def approximate_message_lines (text : str ):
9081 """Yield approximate line numbers for <message> elements."""
9182 lines = text .splitlines ()
@@ -103,31 +94,26 @@ def approximate_message_lines(text: str):
10394def check_language_header (ts_file : Path , root , file_lang : str ):
10495 header_lang = root .attrib .get ("language" , "" )
10596 if header_lang != file_lang :
106- return [WarningItem (ts_file , 0 , file_lang ,
107- f"Language header mismatch '{ header_lang } ' != '{ file_lang } '" ,
108- Severity .WARNING )]
97+ msg = f"Language header mismatch '{ header_lang } ' != '{ file_lang } '"
98+ return [WarningItem (ts_file , 0 , file_lang , msg , Severity .WARNING )]
10999 return []
110100
111101
112102def check_empty_translation (ctx : MessageContext ):
113103 if not ctx .translation .strip () and ctx .tr_type != "unfinished" :
114- return [WarningItem (ctx .ts_file , ctx .line , ctx .lang ,
115- f"Empty translation for '{ ctx .excerpt } '" ,
116- Severity .SEVERE )]
104+ msg = f"Empty translation for '{ ctx .excerpt } '"
105+ return [WarningItem (ctx .ts_file , ctx .line , ctx .lang , msg , Severity .SEVERE )]
117106 return []
118107
119108
120109def check_placeholders (ctx : MessageContext ):
121110 if ctx .tr_type == "unfinished" :
122111 return []
123-
124- src_counts = Counter (PLACEHOLDER_RE .findall (ctx .source ))
125- tr_counts = Counter (PLACEHOLDER_RE .findall (ctx .translation ))
126-
127- if src_counts != tr_counts :
112+ src_cnt = Counter (PLACEHOLDER_RE .findall (ctx .source ))
113+ tr_cnt = Counter (PLACEHOLDER_RE .findall (ctx .translation ))
114+ if src_cnt != tr_cnt :
128115 msg = (f"Placeholder mismatch for '{ ctx .excerpt } '\n "
129- f"Source: { ctx .source } \n "
130- f"Trans: { ctx .translation } " )
116+ f"Source: { ctx .source } \n Trans: { ctx .translation } " )
131117 return [WarningItem (ctx .ts_file , ctx .line , ctx .lang , msg , Severity .WARNING )]
132118 return []
133119
@@ -136,149 +122,114 @@ def check_html(ctx: MessageContext):
136122 if (HTML_TAG_RE .search (ctx .source ) and not HTML_TAG_RE .search (ctx .translation )
137123 and ctx .tr_type != "unfinished" ):
138124 msg = (f"HTML missing for '{ ctx .excerpt } '\n "
139- f"Source: { ctx .source } \n "
140- f"Trans: { ctx .translation } " )
125+ f"Source: { ctx .source } \n Trans: { ctx .translation } " )
141126 return [WarningItem (ctx .ts_file , ctx .line , ctx .lang , msg , Severity .WARNING )]
142127 return []
143128
144129
145130def check_whitespace (ctx : MessageContext ):
146131 if not ctx .translation or ctx .tr_type == "unfinished" :
147132 return []
148-
149133 src_lead = ctx .source != ctx .source .lstrip ()
150134 src_trail = ctx .source != ctx .source .rstrip ()
151135 tr_lead = ctx .translation != ctx .translation .lstrip ()
152136 tr_trail = ctx .translation != ctx .translation .rstrip ()
153-
154137 if src_lead != tr_lead or src_trail != tr_trail :
155- return [WarningItem (ctx .ts_file , ctx .line , ctx .lang ,
156- f"Leading/trailing whitespace mismatch for '{ ctx .excerpt } '" ,
157- Severity .WARNING )]
138+ msg = f"Leading/trailing whitespace mismatch for '{ ctx .excerpt } '"
139+ return [WarningItem (ctx .ts_file , ctx .line , ctx .lang , msg , Severity .WARNING )]
158140 return []
159141
160142
161143def check_newline_consistency (ctx : MessageContext ):
162144 if ctx .source .endswith ("\n " ) != ctx .translation .endswith ("\n " ):
163- return [WarningItem (ctx .ts_file , ctx .line , ctx .lang ,
164- f"Newline mismatch for '{ ctx .excerpt } '" ,
165- Severity .WARNING )]
145+ msg = f"Newline mismatch for '{ ctx .excerpt } '"
146+ return [WarningItem (ctx .ts_file , ctx .line , ctx .lang , msg , Severity .WARNING )]
166147 return []
167148
168149
169150def _extract_message_data (message ):
170- """Helper to extract translation data from a message element."""
171- source_elem = message .find ("source" )
172- source = "" .join (source_elem .itertext ()) if source_elem is not None else ""
173-
151+ src_node = message .find ("source" )
152+ source = "" .join (src_node .itertext ()) if src_node is not None else ""
174153 tr_elem = message .find ("translation" )
175- tr_type = ""
176- translation = ""
154+ tr_type , translation = "" , ""
177155 if tr_elem is not None :
178156 tr_type = tr_elem .attrib .get ("type" , "" )
179- numerus_forms = tr_elem .findall ("numerusform" )
180- if numerus_forms :
181- translation = " " .join ("" .join (n .itertext ()) for n in numerus_forms )
157+ forms = tr_elem .findall ("numerusform" )
158+ if forms :
159+ translation = " " .join ("" .join (n .itertext ()) for n in forms )
182160 else :
183161 translation = "" .join (tr_elem .itertext ())
184-
185162 return source , translation , tr_type
186163
187164
188- # Detect warnings
165+ def _process_context (ts_file , file_lang , context , line_gen ):
166+ warnings = []
167+ for message in context .findall ("message" ):
168+ line = next (line_gen , 0 )
169+ src , trans , tr_type = _extract_message_data (message )
170+ clean = src .strip ().replace ("\n " , " " )
171+ excerpt = clean [:30 ] + ("..." if len (clean ) > 30 else "" )
172+ ctx = MessageContext (ts_file , line , file_lang , src , trans , tr_type , excerpt )
173+ for check in [check_empty_translation , check_placeholders , check_html ,
174+ check_whitespace , check_newline_consistency ]:
175+ warnings .extend (check (ctx ))
176+ return warnings
177+
178+
189179def detect_warnings (ts_file : Path , file_lang : str ):
190180 try :
191181 text = ts_file .read_text (encoding = "utf-8" )
192182 root = ET .fromstring (text )
193183 except (OSError , ET .ParseError ) as exc :
194- return [WarningItem (ts_file , 0 , file_lang ,
195- f"Error reading or parsing XML: { exc } " ,
196- Severity .SEVERE )]
197-
198- warnings = []
199- warnings .extend (check_language_header (ts_file , root , file_lang ))
200- message_lines = approximate_message_lines (text )
184+ return [WarningItem (ts_file , 0 , file_lang , f"Error parsing XML: { exc } " , Severity .SEVERE )]
201185
186+ warnings = check_language_header (ts_file , root , file_lang )
187+ line_gen = approximate_message_lines (text )
202188 for context in root .findall ("context" ):
203- for message , line in zip (context .findall ("message" ), message_lines ):
204- source , translation , tr_type = _extract_message_data (message )
205-
206- source_clean = source .strip ().replace ("\n " , " " )
207- excerpt = source_clean [:30 ] + ("..." if len (source_clean ) > 30 else "" )
208-
209- ctx = MessageContext (ts_file , line , file_lang , source , translation , tr_type , excerpt )
189+ warnings .extend (_process_context (ts_file , file_lang , context , line_gen ))
190+ return warnings
210191
211- # All checks
212- for check in [check_empty_translation , check_placeholders , check_html ,
213- check_whitespace , check_newline_consistency ]:
214- warnings .extend (check (ctx ))
215192
216- return warnings
193+ def _print_results (grouped ):
194+ for file in sorted (grouped .keys ()):
195+ print (f"\n { BOLD } File: { file .name } { RESET } " )
196+ for w in sorted (grouped [file ], key = lambda x : x .line ):
197+ color , sev = (RED , "SEVERE " ) if w .severity == Severity .SEVERE else (YELLOW , "WARNING" )
198+ lines = w .message .split ("\n " )
199+ print (f" { CYAN } Line { w .line :<4} { RESET } | { color } { sev } { RESET } | { lines [0 ]} " )
200+ for extra in lines [1 :]:
201+ print (f" | | { extra } " )
217202
218203
219- # CLI
220204def main ():
221- parser = argparse .ArgumentParser (description = "Qt TS translation checker" )
222- parser .add_argument ("--ts-dir" , type = Path , default = Path ("../src/translation" ),
223- help = "Directory containing translation_*.ts files" )
224- parser .add_argument ("--strict" , action = "store_true" ,
225- help = "Exit non-zero if any warning is found" )
205+ parser = argparse .ArgumentParser ()
206+ parser .add_argument ("--ts-dir" , type = Path , default = Path ("../src/translation" ))
207+ parser .add_argument ("--strict" , action = "store_true" )
226208 args = parser .parse_args ()
227209
228- if not args .ts_dir .exists ():
229- print (f"Directory not found: { args .ts_dir } " , file = sys .stderr )
230- return 2
231-
232210 ts_files = sorted (args .ts_dir .glob ("translation_*.ts" ))
233211 if not ts_files :
234- print (f"No TS files found in { args .ts_dir } " , file = sys .stderr )
235212 return 2
236213
237- all_warnings = []
238- failures_by_language = defaultdict (lambda : {"severe" : 0 , "warning" : 0 })
214+ all_warnings , stats = [], defaultdict (lambda : {"severe" : 0 , "warning" : 0 })
215+ for f in ts_files :
216+ all_warnings .extend (detect_warnings (f , f .stem .replace ("translation_" , "" )))
239217
240- for ts_file in ts_files :
241- lang = ts_file .stem .replace ("translation_" , "" )
242- all_warnings .extend (detect_warnings (ts_file , lang ))
243-
244- # Group output by file
245218 grouped = defaultdict (list )
246219 for w in all_warnings :
247220 grouped [w .ts_file ].append (w )
221+ stats [w .lang ]["severe" if w .severity == Severity .SEVERE else "warning" ] += 1
248222
249- if w .severity == Severity .SEVERE :
250- failures_by_language [w .lang ]["severe" ] += 1
251- else :
252- failures_by_language [w .lang ]["warning" ] += 1
253-
254- # Detailed clean column output
255- for file in sorted (grouped .keys ()):
256- messages = grouped [file ]
257- print (f"\n { BOLD } File: { file .name } { RESET } " )
258-
259- for w in sorted (messages , key = lambda x : x .line ):
260- color = RED if w .severity == Severity .SEVERE else YELLOW
261- sev_text = "SEVERE " if w .severity == Severity .SEVERE else "WARNING"
223+ _print_results (grouped )
262224
263- msg_lines = w .message .split ("\n " )
264- print (f" { CYAN } Line { w .line :<4} { RESET } | { color } { sev_text } { RESET } | { msg_lines [0 ]} " )
265-
266- for extra_line in msg_lines [1 :]:
267- print (f" | | { extra_line } " )
268-
269- # Test summary
270225 print ("\n == Test Summary ==" )
271- for lang in sorted (failures_by_language .keys ()):
272- counts = failures_by_language [lang ]
273- print ( f" { BOLD } [ { lang } ] { RESET } Severe: { counts [ 'severe' ] } , Warnings: { counts ['warning' ]} " )
226+ for lang in sorted (stats .keys ()):
227+ print ( f" { BOLD } [ { lang } ] { RESET } Severe: { stats [lang ][ 'severe' ] } , "
228+ f" Warnings: { stats [ lang ] ['warning' ]} " )
274229
275- total_severe = sum (f ["severe" ] for f in failures_by_language .values ())
276- total_warning = sum (f ["warning" ] for f in failures_by_language .values ())
277- print (f"\n Total Severe: { total_severe } , Total Warnings: { total_warning } " )
278-
279- if total_severe > 0 or (args .strict and total_warning > 0 ):
230+ if sum (s ["severe" ] for s in stats .values ()) > 0 or (
231+ args .strict and sum (s ["warning" ] for s in stats .values ()) > 0 ):
280232 return 1
281-
282233 return 0
283234
284235
0 commit comments