Files
regexx/tests/gen.py
T
retoorandClaude Sonnet 5 b0991814cb Expand BINARY/UTF8/ASCII edge-case testing to 3,252 cases, fix a real LOCALE bug
Three new categories added to tests/cases.py (TEST_PLAN.md records the
detail): Category H exercises BINARY mode with genuinely arbitrary raw
bytes (embedded NUL, high bytes, non-UTF-8 sequences) via real Python
`bytes` subjects, not just UTF-8-encoded text; Category I exercises
UTF8 mode edge cases (4-byte/astral code points, combining marks,
Arabic, Hebrew, CJK, offset correctness across multi-byte characters);
Category J is a fixed-seed (reproducible, not flaky) random generator
combining the existing atom/quantifier/grouping vocabulary across all
three modes.

This found and fixed a real bug, not just a test-generation one:
LOCALE, in BINARY/ASCII mode, treated bytes 0x80-0xFF as word
characters, based on an unverified assumption about what the "C"
locale does. Checked directly against both the C standard's own
guarantee for isalnum() under "C" and a real CPython interpreter with
re.LOCALE and the "C" locale explicitly set, neither treats anything
above 0x7f as a word character. Fixed in regexx.c's cls_is_word;
LOCALE is now documented as an accepted no-op in non-UTF8 mode,
matching verified reality instead of a prior assumption (concept.md
13.4, docs/API.md, README.md "Known deviations").

Two more findings were test-generation bugs, not regexx bugs: gen.py's
own ASCII-mode ground truth used Python str + re.ASCII (code-point
space) instead of a bytes pattern against a bytes subject (what
regexx's byte-oriented ASCII mode actually is), and LOCALE combined
with the (now removed as redundant) auto-added re.ASCII flag raised
ValueError in Python for being an incompatible combination. Both
fixed in gen.py.

Two further findings were concrete instances of an already-documented
category (glibc's wctype.h Unicode tables not matching CPython's own
exactly): U+00A0 and fullwidth digits U+FF10-FF19 are recognized by
CPython's \s/\d but not by glibc's iswspace()/iswdigit() under C.utf8.
Recorded in README.md, not patched, for the reason already given for
the first such instance (NBSP) in the previous commit.

After these fixes: all 3,252 committed cases pass, clean under
AddressSanitizer/UndefinedBehaviorSanitizer. The Category J generator
was additionally run against 5 more seeds at 3,000 iterations each
(26,760 further checks) as exploratory validation, all passing; not
committed, to keep the regular suite's size proportionate.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EjuMk8kY9SDus1wWe2K9xY
2026-09-14 09:25:50 +00:00

227 lines
8.9 KiB
Python

#!/usr/bin/env python3
"""Generate tests/generated_tests.c from tests/cases.py, using CPython's
own `re` module as ground truth (concept.md Section 11)."""
import re
import sys
import os
sys.path.insert(0, os.path.dirname(__file__))
from cases import CASES # noqa: E402
FLAGMAP = {
"IGNORECASE": re.IGNORECASE,
"MULTILINE": re.MULTILINE,
"DOTALL": re.DOTALL,
"VERBOSE": re.VERBOSE,
"LOCALE": re.LOCALE,
"ASCII": re.ASCII,
}
def c_str(b):
"""Encode bytes/str as a C string literal. Escapes '?' as '\\?' (a
standard C escape for a literal '?', otherwise unnecessary) so that
no two-or-more-'?' run can ever form a trigraph sequence (??=, ??),
??!, and friends), which a strict-mode C compiler silently rewrites
INSIDE string literals before the string is ever parsed as string
content: "(?:.??)" (7 bytes) became "(?:.]" (5 bytes) under this
project's own build flags before this fix, corrupting every
generated pattern containing that sequence. Confirmed necessary,
not theoretical, by compiling and printing the corrupted string."""
if isinstance(b, str):
b = b.encode("utf-8")
out = ['"']
for byte in b:
c = chr(byte)
if c == '"':
out.append('\\"')
elif c == "\\":
out.append("\\\\")
elif c == "?":
out.append("\\?")
elif 32 <= byte < 127:
out.append(c)
else:
out.append("\\%03o" % byte)
out.append('"')
return "".join(out)
def py_flags(case):
# re.ASCII is not added here for mode == "ascii": ground truth for
# that mode is already a `bytes` pattern against a `bytes` subject
# (encode(), above), and a bytes pattern is inherently ASCII-only
# for \w/\s/\d regardless of re.ASCII, so adding it would be purely
# redundant, and actively wrong whenever a case also uses LOCALE,
# since Python rejects ASCII and LOCALE combined (confirmed
# directly: ValueError: ASCII and LOCALE flags are incompatible).
f = 0
for name in case["flags"]:
f |= FLAGMAP[name]
return f
def c_flags(case):
names = []
if case["mode"] == "ascii":
names.append("ASCII")
elif case["mode"] == "utf8":
names.append("UTF8")
elif case["mode"] == "binary":
names.append("BINARY")
for name in case["flags"]:
names.append(name)
return "|".join(names) if names else "0"
def encode(case, s):
# ASCII mode is byte oriented, exactly like BINARY (regexx.c's
# resolve_mode/build_matbuf treat them identically: raw bytes, no
# UTF-8 decoding); it differs from BINARY only in which bytes \w/\s/
# \d treat as word/space/digit, not in the character model. Python's
# re.ASCII on a str is not the right ground truth for that: it still
# operates on code points, only restricting \w/\s/\d's *definition*,
# so it silently diverges from a byte-oriented match the moment a
# subject contains a multi-byte UTF-8 character (confirmed directly:
# str+re.ASCII on "naïve" gives (3,5) for the second \w+, the actual
# byte-oriented match is (4,6), and regexx's ASCII mode reports the
# latter, correctly). A `bytes` pattern against a `bytes` subject is
# always byte oriented with ASCII-only \w/\s/\d in Python too, so it
# is the correct ground truth for both ASCII and BINARY mode here.
if isinstance(s, bytes):
return s # already raw bytes (arbitrary, not necessarily valid UTF-8): pass through
if case["mode"] in ("binary", "ascii"):
return s.encode("utf-8")
return s
def blen(case, s):
"""Byte length of the encoded subject, as the C API always wants a
byte count (UTF8 mode still stores a byte buffer; only Match_start
reports code points), never a Python character count."""
e = encode(case, s)
return len(e) if isinstance(e, bytes) else len(e.encode("utf-8"))
def desc(case, i):
return "#%d %s /%s/ on %r" % (i, case["op"], case["pattern"], case["subject"])
def gen_search_family(case, i, out):
kind = {"search": 0, "match": 1, "fullmatch": 2}[case["op"]]
flags = py_flags(case)
subj = encode(case, case["subject"])
pat = encode(case, case["pattern"])
compiled = re.compile(pat, flags)
fn = {"search": compiled.search, "match": compiled.match, "fullmatch": compiled.fullmatch}[case["op"]]
m = fn(subj)
d = desc(case, i)
if m is None:
out.append(' tc_search_family(%s, %s, %s, %s, %d, %d, 0, 0, NULL);' % (
c_str(d), c_str(case["pattern"]), c_flags(case), c_str(case["subject"]), blen(case, case["subject"]), kind))
return
spans = [m.start(0), m.end(0)]
for g in range(1, compiled.groups + 1):
try:
spans += [m.start(g), m.end(g)]
except IndexError:
spans += [-1, -1]
n_spans = compiled.groups + 1
arr = ",".join(str(v) for v in spans)
out.append(' { static const long long sp[] = {%s}; tc_search_family(%s, %s, %s, %s, %d, %d, 1, %d, sp); }' % (
arr, c_str(d), c_str(case["pattern"]), c_flags(case), c_str(case["subject"]), blen(case, case["subject"]), kind, n_spans))
def gen_finditer(case, i, out):
flags = py_flags(case)
subj = encode(case, case["subject"])
pat = encode(case, case["pattern"])
ms = list(re.finditer(pat, subj, flags))
spans = []
for m in ms:
spans += [m.start(0), m.end(0)]
d = desc(case, i)
if not spans:
out.append(' tc_finditer(%s, %s, %s, %s, %d, 0, NULL);' % (
c_str(d), c_str(case["pattern"]), c_flags(case), c_str(case["subject"]), blen(case, case["subject"])))
return
arr = ",".join(str(v) for v in spans)
out.append(' { static const long long sp[] = {%s}; tc_finditer(%s, %s, %s, %s, %d, %d, sp); }' % (
arr, c_str(d), c_str(case["pattern"]), c_flags(case), c_str(case["subject"]), blen(case, case["subject"]), len(ms)))
def gen_sub(case, i, out):
flags = py_flags(case)
subj = encode(case, case["subject"])
pat = encode(case, case["pattern"])
repl = encode(case, case["repl"])
count = case.get("count", 0)
result, n = re.subn(pat, repl, subj, count=count, flags=flags)
d = desc(case, i)
result_str = result.decode("utf-8") if isinstance(result, bytes) else result
out.append(' tc_sub(%s, %s, %s, %s, %d, %s, %d, %s, %d);' % (
c_str(d), c_str(case["pattern"]), c_flags(case), c_str(case["subject"]), blen(case, case["subject"]),
c_str(case["repl"]), count, c_str(result_str), n))
def gen_split(case, i, out):
flags = py_flags(case)
subj = encode(case, case["subject"])
pat = encode(case, case["pattern"])
maxsplit = case.get("maxsplit", 0)
items = re.split(pat, subj, maxsplit=maxsplit, flags=flags)
d = desc(case, i)
parts = []
for it in items:
if it is None:
parts.append("NULL")
else:
s = it.decode("utf-8") if isinstance(it, bytes) else it
parts.append(c_str(s))
arr = ",".join(parts) if parts else "0"
out.append(' { static const char *const items[] = {%s}; tc_split(%s, %s, %s, %s, %d, %d, %d, items); }' % (
arr, c_str(d), c_str(case["pattern"]), c_flags(case), c_str(case["subject"]), blen(case, case["subject"]),
maxsplit, len(items)))
def main():
out = []
out.append('/* GENERATED by tests/gen.py from tests/cases.py. Do not edit by hand. */')
out.append('#include "harness.h"')
out.append('void run_generated_tests(void) {')
emitted = 0
skipped = 0
for i, case in enumerate(CASES):
op = case["op"]
if op not in ("search", "match", "fullmatch", "finditer", "sub", "split"):
raise ValueError("unknown op %r" % op) # a typo in a hand-written case, not a skippable combination
try:
if op in ("search", "match", "fullmatch"):
gen_search_family(case, i, out)
elif op == "finditer":
gen_finditer(case, i, out)
elif op == "sub":
gen_sub(case, i, out)
elif op == "split":
gen_split(case, i, out)
emitted += 1
except (re.error, ValueError) as e:
# A combinatorially generated pattern that CPython itself
# cannot compile is not a useful ground-truth case (there is
# nothing to check regexx against); skip it rather than
# aborting the whole generation run. Hand-written cases are
# never expected to hit this path, so seeing it fire on one
# is a signal to look at the offending combination, not to
# silently rely on the skip.
skipped += 1
print("skip #%d (%s %r): %s" % (i, op, case["pattern"], e), file=sys.stderr)
out.append('}')
dest = os.path.join(os.path.dirname(__file__), "generated_tests.c")
with open(dest, "w") as f:
f.write("\n".join(out) + "\n")
print("wrote %s (%d cases emitted, %d skipped)" % (dest, emitted, skipped))
if __name__ == "__main__":
main()