Files
regexx/tests/gen.py
T
retoorandClaude Sonnet 5 51f8ed19e9 Expand the test suite to 2,247 combinatorial cases, fix two real bugs it found
tests/TEST_PLAN.md records the plan before the result: seven categories
(quantifier x grouping x flags, alternation x backreference x groups,
lookaround combinatorics, sub/split specifics, real-world combination
patterns, mode cross-checks, flag combination stress), each generated
programmatically in tests/cases.py rather than hand-typed, still every
case checked against a real CPython re result computed by tests/gen.py,
per concept.md Section 11's existing strategy, just at 2,247 cases
instead of 81.

Running it immediately found two real, previously latent bugs, not
just confirmed correctness of what was already covered by hand:

- tests/gen.py's own C string-literal encoder had a trigraph bug: any
  generated pattern containing `??)` (the lazy quantifier next to a
  closing paren) was silently rewritten by the C compiler from 7 bytes
  to 5 before the suite ever ran, confirmed directly by compiling and
  printing the corrupted string. Fixed by escaping '?' as '\?', which
  is always safe and makes trigraph formation impossible.
- Pattern_finditer/Pattern_split had a real, previously undocumented
  correctness defect: CPython's empty-match handling additionally
  searches for, and reports, a second, non-empty match at the same
  start position whenever the natural match found there was empty
  (reverse engineered against a real interpreter, since this is not
  written down in CPython's own documentation; \d*? against
  "123abc456" yields 16 matches, not 9). Fixed with a new MCtx
  forbid_empty flag that forces exactly that second search by
  rejecting the empty solution at OP_MATCH and letting ordinary
  backtracking find the next alternative, wired into both functions
  (they have independent scan loops). concept.md Section 3 and
  docs/API.md now state the rule precisely instead of the previous,
  incomplete description.

A third, more mundane finding: the combinatorial mode cross-check
category surfaced that ASCII-mode ground truth was being computed
wrong in tests/gen.py itself (Python str + re.ASCII, which stays in
code-point space, instead of a bytes pattern against a bytes subject,
which is what regexx's byte-oriented ASCII mode actually is), and
separately surfaced a genuine, now precisely documented Unicode-table
gap already anticipated in principle by README.md's "Known deviations"
(glibc's iswspace() under the C.utf8 locale does not classify U+00A0
NO-BREAK SPACE as whitespace; CPython's \s does).

All 2,247 cases pass, clean under AddressSanitizer/UndefinedBehavior-
Sanitizer; the 50MB/quadratic-time and ReDoS-scaling benchmarks from
the previous two commits are unaffected.

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

220 lines
8.2 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):
f = 0
if case["mode"] == "ascii":
f |= re.ASCII
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 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"]
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)
else:
raise ValueError("unknown op %r" % op)
emitted += 1
except re.error 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()