88 be translated, matching Sphinx's own translation convention.
99 - Literal/code spans (``...``), substitution refs (|...|), and %s/{name}
1010 placeholders — these must match verbatim, since they're not prose.
11+ - Invalid C-string escape sequences in msgstr (a lone backslash followed
12+ by a character that isn't one of \\ " n t r f b a v) — these make
13+ `msgfmt` fail with "invalid control sequence" and must be found before
14+ they break a build.
1115
1216Output is grouped by file, with a per-file mismatch count and a grand
1317total at the end.
4448 ("brace placeholder" , re .compile (r"\{[^{}\s]*\}" )),
4549]
4650
51+ # Valid C-string escapes that gettext/msgfmt accept inside a quoted
52+ # string. A backslash followed by anything else is what msgfmt rejects
53+ # with "invalid control sequence". This mirrors the set the old
54+ # autofix bash script treated as "leave alone": \\ \" \n \t \r \f \b \a \v
55+ VALID_ESCAPE_CHARS = set ('\\ "ntrfbav' )
56+
57+ # One raw quoted-string line as it appears in the .po file. Matches
58+ # both the first line of an entry, which carries a keyword prefix
59+ # (msgid "..." / msgstr "..." / msgstr[0] "..."), and bare continuation
60+ # lines ("...") that follow it. Captured group is the RAW content
61+ # between the quotes - escapes are NOT decoded, which is required for
62+ # this check (see note below).
63+ RAW_STRING_LINE = re .compile (
64+ r'^(?:msgid|msgstr(?:\[\d+\])?|msgctxt)?\s*"((?:[^"\\]|\\.)*)"\s*$'
65+ )
66+
67+
68+ def find_invalid_escapes_in_raw (raw : str ):
69+ """Scan raw (undecoded) quoted-string content left-to-right the way
70+ a C-string tokenizer would, consuming two characters whenever a
71+ backslash is seen, and return the invalid `\\ X` sequences found.
72+
73+ MUST run on raw file text, not on polib's .msgid/.msgstr - polib
74+ decodes \\ \\ into a single \\ before Claude ever sees it, so scanning
75+ decoded text turns safe, doubled backslashes (\\ \\ d in the file,
76+ meaning a literal backslash-d) into false positives that look like
77+ a lone backslash followed by an invalid character."""
78+ invalid = []
79+ i = 0
80+ n = len (raw )
81+ while i < n :
82+ if raw [i ] == "\\ " :
83+ if i + 1 < n :
84+ nxt = raw [i + 1 ]
85+ if nxt not in VALID_ESCAPE_CHARS :
86+ invalid .append ("\\ " + nxt )
87+ i += 2
88+ continue
89+ else :
90+ invalid .append ("\\ <end-of-line>" )
91+ i += 1
92+ continue
93+ i += 1
94+ return invalid
95+
96+
97+ def check_raw_escapes (path : Path ):
98+ """Line-by-line scan of the raw file for invalid escape sequences,
99+ independent of polib. Returns a list of (line_no, line_text,
100+ invalid_list) for every quoted-string line with a problem."""
101+ findings = []
102+ for lineno , line in enumerate (
103+ path .read_text (encoding = "utf-8" ).splitlines (), start = 1
104+ ):
105+ m = RAW_STRING_LINE .match (line .strip ())
106+ if not m :
107+ continue
108+ invalid = find_invalid_escapes_in_raw (m .group (1 ))
109+ if invalid :
110+ findings .append ((lineno , line .strip (), invalid ))
111+ return findings
112+
47113
48114def extract_role_targets (text : str ):
49115 """For each Sphinx role, return its target: the <target> anchor if
@@ -60,6 +126,7 @@ def check_file(path: Path):
60126 """Return a list of finding-dicts for this file (empty if none)."""
61127 results = []
62128 po = polib .pofile (str (path ))
129+
63130 for entry in po :
64131 if entry .obsolete or not entry .msgid or not entry .msgstr :
65132 continue # obsolete entry, header, or still untranslated
@@ -134,6 +201,8 @@ def main():
134201
135202 total = 0
136203 per_file_counts = []
204+ escape_total = 0
205+ escape_files = 0
137206
138207 for f in files :
139208 results = check_file (f )
@@ -142,17 +211,38 @@ def main():
142211 total += len (results )
143212 per_file_counts .append ((f , len (results )))
144213
214+ escape_findings = check_raw_escapes (f )
215+ if escape_findings :
216+ print (f"\n { '=' * 70 } " )
217+ print (f"{ f } ({ len (escape_findings )} invalid escape sequence line(s))" )
218+ print ("=" * 70 )
219+ for lineno , line , invalid in escape_findings :
220+ print (f" line { lineno } : invalid { invalid } " )
221+ print (f" { line [:120 ]} " )
222+ escape_total += len (escape_findings )
223+ escape_files += 1
224+
145225 if total :
146226 print (f"\n { '=' * 70 } " )
147227 print ("Summary by file (sorted by mismatch count, descending):" )
148228 print ("=" * 70 )
149229 for f , count in sorted (per_file_counts , key = lambda x : - x [1 ]):
150230 print (f" { count :4d} { f } " )
151231 print (f"\n { total } markup mismatch(es) found across { len (per_file_counts )} file(s)." )
232+
233+ if escape_total :
234+ print (
235+ f"\n { escape_total } invalid escape sequence line(s) found across "
236+ f"{ escape_files } file(s). These will make msgfmt fail with "
237+ f"'invalid control sequence' - fix the offending backslash "
238+ f"in each line above."
239+ )
240+
241+ if total or escape_total :
152242 sys .exit (1 )
153243
154244 print ("No markup mismatches found." )
155245
156246
157247if __name__ == "__main__" :
158- main ()
248+ main ()
0 commit comments