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
227 lines
8.9 KiB
Python
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()
|