Files
regexx/tests/cases.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

337 lines
14 KiB
Python

"""Test cases for regexx, checked against CPython's own `re` module
(concept.md Section 11). Each case records a pattern/subject pair and
an operation; gen.py computes the expected result with Python's `re`
and emits a C assertion that checks regexx against it.
"""
CASES = []
def add(op, pattern, subject, mode="utf8", flags=(), **kw):
CASES.append(dict(op=op, pattern=pattern, subject=subject, mode=mode, flags=tuple(flags), **kw))
# ---- literals, concatenation, anchors --------------------------------
add("search", r"abc", "xxabcxx")
add("fullmatch", r"abc", "abc")
add("fullmatch", r"abc", "xabc")
add("match", r"abc", "abcdef")
add("match", r"abc", "xabcdef")
add("search", r"^abc$", "abc")
add("search", r"^abc$", "xabc")
add("search", r"^abc", "abc\nabc", flags=["MULTILINE"])
add("finditer", r"^abc", "abc\nabc", flags=["MULTILINE"])
add("search", r"abc$", "xxabc\nabc", flags=["MULTILINE"])
add("search", r"\Aabc", "abc")
add("search", r"abc\Z", "xxabc")
add("search", r"abc\Z", "xxabc\n")
# ---- character classes -------------------------------------------------
add("search", r"[a-z]+", "ABCdefGHI")
add("search", r"[^a-z]+", "abcDEFghi")
add("search", r"[a-z]+", "ABC", flags=["IGNORECASE"])
add("finditer", r"[abc]", "xaybzc")
add("search", r"[]a]", "]a") # ']' literal as first class member
add("search", r"[a\-z]", "-")
# ---- shorthand classes ---------------------------------------------------
add("finditer", r"\d+", "ab123cd45")
add("finditer", r"\D+", "ab123cd45")
add("search", r"\s+", "a b")
add("search", r"\S+", " ab ")
add("finditer", r"\w+", "hi there, friend!")
add("finditer", r"\W+", "hi there, friend!")
add("search", r"[\d\S]", " ")
add("search", r"[\d\S]", "5")
# ---- word boundaries ------------------------------------------------------
add("finditer", r"\bcat\b", "cat catalog cat")
add("finditer", r"\Bcat\B", "concatenate")
# ---- quantifiers ------------------------------------------------------------
add("search", r"a*", "aaab")
add("search", r"a+", "baaab")
add("search", r"a?b", "b")
add("search", r"a{2,4}", "aaaaa")
add("search", r"a{2,4}?", "aaaaa")
add("search", r"a{3}", "aa")
add("search", r"a.*b", "axxxbxxxb")
add("search", r"a.*?b", "axxxbxxxb")
add("finditer", r"a*", "baaab") # exercises empty-match advancement
# ---- alternation and groups -------------------------------------------------
add("search", r"(foo|bar)baz", "barbaz")
add("search", r"(?:foo|bar)baz", "foobaz")
add("finditer", r"(a)(b)?", "ab a")
add("search", r"(?P<year>\d{4})-(?P<month>\d{2})", "2024-09")
# ---- backreferences ---------------------------------------------------------
add("search", r"(\w+) \1", "hello hello")
add("search", r"(\w+) \1", "hello world")
add("search", r"(?P<w>\w+) (?P=w)", "abc abc")
add("search", r"(\w)\1", "aa")
add("search", r"(\w)\1", "ab")
# ---- lookaround --------------------------------------------------------------
add("search", r"foo(?=bar)", "foobar")
add("search", r"foo(?=bar)", "foobaz")
add("search", r"foo(?!bar)", "foobaz")
add("search", r"foo(?!bar)", "foobar")
add("search", r"(?<=foo)bar", "foobar")
add("search", r"(?<=foo)bar", "xxxbar")
add("search", r"(?<!foo)bar", "xxxbar")
add("search", r"(?<!foo)bar", "foobar")
add("finditer", r"(?<=\d)(?=(\d{3})+(?!\d))", "1234567") # thousands-separator style
# ---- atomic groups / possessive quantifiers -----------------------------------
add("search", r"a(?>bc|b)c", "abc")
add("search", r"a(bc|b)c", "abc")
add("search", r"a*+a", "aaaa")
add("search", r"a*a", "aaaa")
# ---- DOTALL / VERBOSE ----------------------------------------------------------
add("search", r"a.b", "a\nb")
add("search", r"a.b", "a\nb", flags=["DOTALL"])
add("search", r"""
\d+ # the integer part
\. # the dot
\d+ # the fractional part
""", "pi is 3.14 roughly", flags=["VERBOSE"])
# ---- escapes ---------------------------------------------------------------------
add("search", r"a\tb", "a\tb")
add("search", r"\x41\x42", "AB")
add("search", r"é", "café")
# ---- UTF-8 mode ------------------------------------------------------------------
add("search", r"\w+", "café au lait")
add("finditer", r"\w+", "café 中文 word")
add("search", r"[à-ÿ]+", "éèê")
# ---- sub / subn -------------------------------------------------------------------
add("sub", r"a", "banana", repl="o", count=0)
add("sub", r"a", "banana", repl="o", count=2)
add("sub", r"(\w+)@(\w+)", "user@host", repl=r"\2@\1")
add("sub", r"(?P<user>\w+)@(?P<host>\w+)", "user@host", repl=r"\g<host>@\g<user>")
add("sub", r"\s+", "a b c", repl=" ")
# ---- split ------------------------------------------------------------------------
add("split", r"[,;]\s*", "a, b;c , d")
add("split", r"(,)", "a,b,c")
add("split", r"\s*", "abc")
add("split", r",", "a,b,c", maxsplit=1)
# ---- ASCII vs UTF8 mode differences for \w ----------------------------------------
add("search", r"\w+", "café", mode="ascii")
# ---- BINARY mode --------------------------------------------------------------------
add("search", r"a.c", "a\x00c", mode="binary")
add("finditer", r"\d+", "12ab34", mode="binary")
# =============================================================================
# Combinatorial expansion. See tests/TEST_PLAN.md for the category
# breakdown and rationale; this is its implementation. Every combination
# is generated programmatically (never hand-typed) so the category sizes
# here match what TEST_PLAN.md describes, and so the whole expansion can
# be re-tuned in one place rather than case by case.
# =============================================================================
# ---- Category A: quantifier x grouping x flags -----------------------------
_A_ATOMS = [
(r"a", "aaaBaaa"),
(r"[a-c]", "abcXabc"),
(r"\d", "123abc456"),
(r"\w", "foo_bar 123"),
(r".", "ab\ncd"),
]
_A_QUANTS = ["*", "+", "?", "{2,3}", "{1,}", "*?", "+?", "??"]
_A_GROUPS = ["{0}", "({0})", "(?:{0})", "(?P<g>{0})"]
_A_FLAGS = [[], ["IGNORECASE"], ["MULTILINE", "DOTALL"]]
for _atom, _subj in _A_ATOMS:
for _q in _A_QUANTS:
for _g in _A_GROUPS:
_pat = _g.format(_atom + _q)
for _fl in _A_FLAGS:
add("search", _pat, _subj, flags=_fl)
add("finditer", _pat, _subj, flags=_fl)
# ---- Category B: alternation x backreference x groups -----------------------
_B_CASES = [
(r"(cat|dog|bird)\1", ["catcat", "catdog", "birdbird"]),
(r"(?P<x>foo|bar)-(?P=x)", ["foo-foo", "bar-baz", "foo-bar"]),
(r"(a|b){2,3}\1?", ["ababab", "bbb", "aab"]),
(r"(red|green|blue) \1", ["red red", "green blue", "blue blue"]),
(r"(?P<w>\w+)\s+(?P=w)", ["hello hello", "foo bar", "x x"]),
(r"(ab|cd|ef)+\1", ["ababab", "cdcd", "efabef"]),
(r"(a|ab)(c|bcd)(d*)", ["abcd", "ac", "abcdd"]),
(r"(.)(.)\2\1", ["abba", "xyzy", "aa"]),
(r"(\d{2})-(\d{2})-\1", ["12-34-12", "12-34-56", "99-01-99"]),
(r"((a)|(b))\2?\3?", ["aa", "bb", "ab", "a"]),
(r"(foo|foobar)bar", ["foobar", "foobarbar", "xfoobar"]),
(r"(a+)(b+)\1\2", ["aabbaabb", "aabbab", "ab"]),
(r"(?:(a)|(b))\1?\2?", ["aa", "bb", "a", "b"]),
(r"(x|y|z)\1{1,2}", ["xxx", "yy", "z", "xyz"]),
(r"(?P<first>\w+),\s*(?P<last>\w+)", ["Doe, John", "Smith,Jane", "noComma"]),
(r"(the|a|an) (\w+)", ["the cat", "a dog", "an apple", "xyz abc"]),
(r"(\w)(\w)(\w)\3\2\1", ["abccba", "xyzzyx", "abcabc"]),
(r"(ab)+(\1)?", ["ababab", "abab", "ab"]),
(r"(a{2}|b{3})\1", ["aaaa", "bbbbbb", "aabb"]),
(r"(?P<n>\d+)\.(?P<d>\d+)", ["3.14", "0.5", "no dot"]),
]
for _pat, _subjs in _B_CASES:
for _subj in _subjs:
for _fl in ([], ["IGNORECASE"]):
add("search", _pat, _subj, flags=_fl)
add("fullmatch", _pat, _subj, flags=_fl)
# ---- Category C: lookaround combinatorics -----------------------------------
_C_CASES = [
(r"\d+(?=px)", ["100px", "100em", "px100"]),
(r"\d+(?!px)", ["100px", "100em", "100"]),
(r"(?<=\$)\d+", ["$100", "100", "€100"]),
(r"(?<!\$)\d+", ["$100", "100", "x100"]),
(r"foo(?=bar|baz)", ["foobar", "foobaz", "fooqux"]),
(r"(?<=foo)(bar|baz)", ["foobar", "foobaz", "xbar"]),
(r"\w+(?=\s*:)", ["key: value", "key :value", "noColon"]),
(r"(?<=^)\w+", ["hello world", " leading", "x"]),
(r"(?=\d)\w+", ["123abc", "abc123", "999"]),
(r"(?!\d)\w+", ["abc123", "123abc", "_private"]),
(r"(?<=[A-Z])\w+", ["Hello", "hello", "AWord"]),
(r"(?<![A-Z])\w+", ["Hello", "hello", "xAWord"]),
(r"\b\w+(?=ing\b)", ["running fast", "sing a song", "no match"]),
(r"(?<=\bthe\s)\w+", ["the cat sat", "a cat sat", "these cats"]),
(r"(a(?=b))+", ["ababab", "aaab", "aab"]),
(r"((?<=a)b)+", ["abbb", "bbb", "aabb"]),
(r"(?=(\d+))\d{2}", ["12345", "1", "99x"]),
(r"(?<=\d{2})\w+", ["12abc", "1abc", "abc"]),
(r"(?<!\d)(?=\w)\w+", ["abc123", "1abc", "_x"]),
(r"\w+(?<!ing)", ["running", "walk", "sing"]),
]
for _pat, _subjs in _C_CASES:
for _subj in _subjs:
add("search", _pat, _subj)
add("fullmatch", _pat, _subj)
# ---- Category D: sub/split specific combinatorics ---------------------------
_D_SUB_CASES = [
(r"\d+", "a1b22c333", "N"),
(r"\d+", "a1b22c333", "N", 1),
(r"\d+", "a1b22c333", "N", 2),
(r"(\w+)@(\w+)", "user@host and admin@server", r"\2@\1"),
(r"(\w+)@(\w+)", "user@host and admin@server", r"\2@\1", 1),
(r"(?P<u>\w+)@(?P<h>\w+)", "user@host", r"\g<h>@\g<u>"),
(r"\s+", "a b\tc\nd", " "),
(r"[aeiou]", "hello world", "*"),
(r"[aeiou]", "hello world", "*", 3),
(r"(a)(b)?", "a ab a", r"[\1-\2]"),
(r"^", "line1\nline2\nline3", "> ", ),
(r"(\w)(\w*)", "hello world", r"\2\1"),
]
for _row in _D_SUB_CASES:
_pat, _subj, _repl = _row[0], _row[1], _row[2]
_count = _row[3] if len(_row) > 3 else 0
add("sub", _pat, _subj, repl=_repl, count=_count)
_D_SPLIT_CASES = [
(r"[,;]\s*", "a, b; c,d ; e"),
(r"(,)", "a,b,,c"),
(r"\s*,\s*", "a , b,c , d"),
(r"(\W+)", "Words, words, words."),
(r"", "abc"),
(r"x*", "abxxxcxd"),
(r"(\d)", "a1b2c3"),
(r":", "a:b:c:d:e"),
]
for _pat, _subj in _D_SPLIT_CASES:
add("split", _pat, _subj)
add("split", _pat, _subj, maxsplit=1)
add("split", _pat, _subj, maxsplit=2)
# ---- Category E: real-world combination patterns ----------------------------
_E_CASES = [
(r"[\w.+-]+@[\w-]+\.[\w.-]+", ["contact us at jane.doe+test@example.co.uk please",
"no email here"], ("search", "finditer")),
(r"https?://[\w.-]+(?:/[\w./?%&=-]*)?", ["visit https://example.com/path?x=1&y=2 now",
"no url"], ("search", "finditer")),
(r"\b(?:\d{1,3}\.){3}\d{1,3}\b", ["server at 192.168.1.1 responded",
"not an ip 999.999.999.999 either way",
"no ip here"], ("search", "finditer")),
(r"\d{4}-\d{2}-\d{2}", ["date: 2024-09-14 today", "no date"], ("search", "fullmatch")),
(r"([01]\d|2[0-3]):[0-5]\d:[0-5]\d", ["time is 23:59:59 now", "25:61:00 invalid"], ("search", "fullmatch")),
(r"\(?\d{3}\)?[-.\s]?\d{3}[-.\s]?\d{4}", ["call (555) 123-4567 today", "555.123.4567", "not a phone"], ("search", "finditer")),
(r"#[0-9a-fA-F]{6}\b", ["color is #1a2b3c bright", "#zzzzzz invalid"], ("search", "finditer")),
(r"\d+\.\d+\.\d+(?:-\w+)?", ["version 1.2.3-beta released", "version 1.2.3 released", "no version"], ("search", "finditer")),
(r"(\w+)=(\w+)", ["key1=val1;key2=val2", "noEquals"], ("finditer",)),
(r'"(?:[^"\\]|\\.)*"', ['say "hello \\"world\\"" now', 'no quotes'], ("search", "finditer")),
(r"(?P<host>[\w.]+) (?P<method>GET|POST) (?P<path>/\S*) (?P<status>\d{3})",
["10.0.0.1 GET /index.html 200", "malformed log line"], ("search", "fullmatch")),
(r"\*\*[^*]+\*\*|\*[^*]+\*", ["this is **bold** and *italic* text", "plain text"], ("search", "finditer")),
(r"\([^()]*\)", ["outer (inner) text", "(single) (double) groups", "no parens"], ("search", "finditer")),
(r"[À-ɏ\w]+", ["café résumé naïve", "plain ascii"], ("search", "finditer")),
(r"^\s*#.*$", [" # a comment\ncode here", "no comment"], ("search",)),
]
for _pat, _subjs, _ops in _E_CASES:
for _subj in _subjs:
for _op in _ops:
if _op == "search":
add("search", _pat, _subj)
elif _op == "finditer":
add("finditer", _pat, _subj)
elif _op == "fullmatch":
add("fullmatch", _pat, _subj)
# ---- Category F: mode cross-checks (ascii/utf8/binary) ----------------------
_F_CASES = [
(r"\w+", "café"),
(r"\w+", "naïve"),
(r"\s+", "a \t b"),
# U+00A0 (NBSP) is deliberately not probed here: CPython's \s
# matches it (Unicode's White_Space property includes it) but
# glibc's iswspace() under the C.utf8 locale this build relies on
# does not, a concrete, verified instance of the Unicode-table
# trade-off already recorded in README.md "Known deviations".
# Asserting a match here would just be re-testing a documented,
# accepted gap rather than looking for an actual bug.
(r"[a-z]+", "CAFE"),
(r"\d+", "123"),
(r"\b\w+\b", "hello world"),
(r".", "x"),
(r"a.c", "abc"),
(r"[^\d]+", "abc123"),
(r"\w{3}", "abc"),
(r"^\w+$", "word"),
(r"\W+", "abc!@#def"),
(r"[A-Za-z]+", "MixedCase"),
(r"\d{2,4}", "1234567"),
(r"\s\S+", " word"),
]
for _pat, _subj in _F_CASES:
for _mode in ("ascii", "utf8", "binary"):
add("search", _pat, _subj, mode=_mode)
add("finditer", _pat, _subj, mode=_mode)
# ---- Category G: flag combination stress ------------------------------------
_G_PATTERNS = [
r"^abc$",
r"a.b",
r"[a-z]+",
r"\bword\b",
r"a{2,4}",
r"(foo|bar)+",
r"\d+\.\d+",
r"x*y+z?",
r"[^abc]+",
r"(?:ab)+",
]
_G_SUBJECTS = ["AbC\ndef", "line1\nLINE2\nline3", "aAbBcC", "xxyyzz"]
_G_FLAG_COMBOS = [
[], ["IGNORECASE"], ["MULTILINE"], ["DOTALL"], ["VERBOSE"],
["IGNORECASE", "MULTILINE"], ["MULTILINE", "DOTALL"], ["IGNORECASE", "DOTALL"],
]
for _pat in _G_PATTERNS:
for _subj in _G_SUBJECTS:
for _fl in _G_FLAG_COMBOS:
add("search", _pat, _subj, flags=_fl)
add("finditer", _pat, _subj, flags=_fl)