Add regexx: a single-file C regex interpreter with Python re semantics

concept.md is the full design document: the objective (Python re parity
plus binary/ASCII/UTF-8 modes, gigabyte-scale input, single C file), the
automata-theory argument for why unrestricted backreferences/lookaround
are incompatible with strict single-pass constant memory, the resulting
two-engine architecture, the exact Python-mirroring naming convention,
and a full comparison against POSIX regex.h for C-background readers.

regexx.c/regexx.h are the v1 implementation: parser, compiler to a
Pike/backtracking-style bytecode, and a single recursive backtracking
engine covering the pattern syntax and operations listed in README.md,
validated against CPython's own re module output (tests/), clean under
AddressSanitizer/UBSan, and stress-tested (50MB simple-quantifier match,
graceful failure rather than a crash on complex repeats over large
input, clean rejection of every intentionally unsupported construct).

Also included: examples/rxgrep.c (a small grep-like program exercising
all three data modes and the substitution API), the Makefile, the MIT
LICENSE, and docs/API.md, an exhaustive reference for every type, flag,
and function's exact return-value and memory-ownership convention,
checked against the current source and against a real CPython
interpreter rather than against memory.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EjuMk8kY9SDus1wWe2K9xY
This commit is contained in:
2026-09-14 05:56:16 +00:00
co-authored by Claude Sonnet 5
commit 8f6afd6cd4
15 changed files with 3329 additions and 0 deletions
+129
View File
@@ -0,0 +1,129 @@
"""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")
+181
View File
@@ -0,0 +1,181 @@
#!/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):
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 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):
if case["mode"] == "binary":
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) {')
for i, case in enumerate(CASES):
op = case["op"]
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)
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)" % (dest, len(CASES)))
if __name__ == "__main__":
main()
+156
View File
@@ -0,0 +1,156 @@
#include "harness.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
int tc_pass = 0, tc_fail = 0;
void tc_search_family(const char *desc, const char *pat, int flags,
const char *subj, size_t subjlen, int kind,
int exp_matched, int n_spans, const long long *exp_spans) {
PatternError err; memset(&err, 0, sizeof err);
Pattern *p = re_compile(pat, strlen(pat), flags, &err);
if (!p) {
printf("FAIL [%s]: compile error: %s\n", desc, err.msg ? err.msg : "?");
tc_fail++;
return;
}
Input *in = Input_from_buffer((const uint8_t *)subj, subjlen);
Match m; memset(&m, 0, sizeof m);
int r;
if (kind == 0) r = Pattern_search(p, in, 0, -1, &m);
else if (kind == 1) r = Pattern_match(p, in, 0, -1, &m);
else r = Pattern_fullmatch(p, in, 0, -1, &m);
if (r < 0) {
printf("FAIL [%s]: internal error (depth exceeded or bad UTF-8)\n", desc);
tc_fail++;
} else if ((r == 1) != (exp_matched != 0)) {
printf("FAIL [%s]: matched=%d expected=%d\n", desc, r, exp_matched);
tc_fail++;
} else if (r == 1) {
int ok = 1;
for (int g = 0; g < n_spans; g++) {
long long es = exp_spans[2 * g], ee = exp_spans[2 * g + 1];
long long as = Match_start(&m, g), ae = Match_end(&m, g);
if (as != es || ae != ee) {
printf("FAIL [%s]: group %d span (%lld,%lld) expected (%lld,%lld)\n", desc, g, as, ae, es, ee);
ok = 0;
}
}
if (ok) tc_pass++; else tc_fail++;
Match_free(&m);
} else {
tc_pass++;
}
Input_free(in);
Pattern_free(p);
}
typedef struct { long long *spans; int n, cap; } SpanList;
static void span_cb(void *ctx, const Match *m_const) {
SpanList *sl = ctx;
Match *m = (Match *)m_const;
if (sl->n == sl->cap) { sl->cap = sl->cap ? sl->cap * 2 : 8; sl->spans = realloc(sl->spans, (size_t)sl->cap * 2 * sizeof(long long)); }
sl->spans[2 * sl->n] = Match_start(m, 0);
sl->spans[2 * sl->n + 1] = Match_end(m, 0);
sl->n++;
}
void tc_finditer(const char *desc, const char *pat, int flags,
const char *subj, size_t subjlen,
int exp_n_matches, const long long *exp_spans) {
PatternError err; memset(&err, 0, sizeof err);
Pattern *p = re_compile(pat, strlen(pat), flags, &err);
if (!p) { printf("FAIL [%s]: compile error: %s\n", desc, err.msg ? err.msg : "?"); tc_fail++; return; }
Input *in = Input_from_buffer((const uint8_t *)subj, subjlen);
SpanList sl; memset(&sl, 0, sizeof sl);
int total = Pattern_finditer(p, in, 0, -1, span_cb, &sl);
if (total < 0) { printf("FAIL [%s]: internal error\n", desc); tc_fail++; }
else if (sl.n != exp_n_matches) { printf("FAIL [%s]: %d matches, expected %d\n", desc, sl.n, exp_n_matches); tc_fail++; }
else {
int ok = 1;
for (int i = 0; i < sl.n; i++)
if (sl.spans[2 * i] != exp_spans[2 * i] || sl.spans[2 * i + 1] != exp_spans[2 * i + 1]) {
printf("FAIL [%s]: match %d span (%lld,%lld) expected (%lld,%lld)\n", desc, i,
sl.spans[2 * i], sl.spans[2 * i + 1], exp_spans[2 * i], exp_spans[2 * i + 1]);
ok = 0;
}
if (ok) tc_pass++; else tc_fail++;
}
free(sl.spans);
Input_free(in);
Pattern_free(p);
}
void tc_sub(const char *desc, const char *pat, int flags,
const char *subj, size_t subjlen, const char *repl, int count,
const char *exp_result, int exp_n) {
PatternError err; memset(&err, 0, sizeof err);
Pattern *p = re_compile(pat, strlen(pat), flags, &err);
if (!p) { printf("FAIL [%s]: compile error: %s\n", desc, err.msg ? err.msg : "?"); tc_fail++; return; }
Input *in = Input_from_buffer((const uint8_t *)subj, subjlen);
char *out = NULL; size_t outlen = 0; int n = 0;
int r = Pattern_subn(p, in, repl, NULL, NULL, count, &out, &outlen, &n);
if (r < 0) { printf("FAIL [%s]: internal error\n", desc); tc_fail++; }
else {
size_t explen = strlen(exp_result);
if (outlen != explen || memcmp(out, exp_result, explen) != 0 || n != exp_n) {
printf("FAIL [%s]: sub result '%.*s' (n=%d) expected '%s' (n=%d)\n", desc, (int)outlen, out, n, exp_result, exp_n);
tc_fail++;
} else tc_pass++;
}
free(out);
Input_free(in);
Pattern_free(p);
}
typedef struct { int present; const char *ptr; size_t len; } SplitItem;
typedef struct { SplitItem *items; int n, cap; } SplitList;
static void split_cb(void *ctx, const Match *m_const) {
SplitList *sl = ctx;
Match *m = (Match *)m_const;
if (sl->n == sl->cap) { sl->cap = sl->cap ? sl->cap * 2 : 8; sl->items = realloc(sl->items, (size_t)sl->cap * sizeof(SplitItem)); }
const char *out; size_t outlen;
int r = Match_group(m, NULL, 0, &out, &outlen);
sl->items[sl->n].present = (r == 1);
sl->items[sl->n].ptr = out; sl->items[sl->n].len = outlen;
sl->n++;
}
void tc_split(const char *desc, const char *pat, int flags,
const char *subj, size_t subjlen, int maxsplit,
int n_items, const char *const *texts) {
PatternError err; memset(&err, 0, sizeof err);
Pattern *p = re_compile(pat, strlen(pat), flags, &err);
if (!p) { printf("FAIL [%s]: compile error: %s\n", desc, err.msg ? err.msg : "?"); tc_fail++; return; }
Input *in = Input_from_buffer((const uint8_t *)subj, subjlen);
SplitList sl; memset(&sl, 0, sizeof sl);
int r = Pattern_split(p, in, maxsplit, split_cb, &sl);
if (r < 0) { printf("FAIL [%s]: internal error\n", desc); tc_fail++; }
else if (sl.n != n_items) { printf("FAIL [%s]: %d items, expected %d\n", desc, sl.n, n_items); tc_fail++; }
else {
int ok = 1;
for (int i = 0; i < sl.n; i++) {
int exp_none = (texts[i] == NULL);
if (exp_none && sl.items[i].present) { printf("FAIL [%s]: item %d expected None\n", desc, i); ok = 0; }
else if (!exp_none) {
size_t explen = strlen(texts[i]);
if (!sl.items[i].present || sl.items[i].len != explen || memcmp(sl.items[i].ptr, texts[i], explen) != 0) {
printf("FAIL [%s]: item %d = '%.*s' expected '%s'\n", desc, i,
sl.items[i].present ? (int)sl.items[i].len : 4,
sl.items[i].present ? sl.items[i].ptr : "NONE", texts[i]);
ok = 0;
}
}
}
if (ok) tc_pass++; else tc_fail++;
}
free(sl.items);
Input_free(in);
Pattern_free(p);
}
void harness_report(void) {
printf("\n%d passed, %d failed\n", tc_pass, tc_fail);
}
+36
View File
@@ -0,0 +1,36 @@
/* tests/harness.h - shared by hand-written and generated test cases.
* Ground truth for generated cases comes from CPython's own `re`
* module (concept.md Section 11); see gen.py. */
#ifndef REGEXX_TEST_HARNESS_H
#define REGEXX_TEST_HARNESS_H
#include "../regexx.h"
#include <stddef.h>
extern int tc_pass, tc_fail;
/* search/match/fullmatch: exp_spans holds 2*n_spans int64_t values,
* (start,end) for group 0, then group 1, 2, ... in order; -1,-1 means
* the group did not participate (or, for group 0 alone, that
* exp_matched is 0 and no spans are checked). */
void tc_search_family(const char *desc, const char *pat, int flags,
const char *subj, size_t subjlen, int kind,
int exp_matched, int n_spans, const long long *exp_spans);
/* finditer: exp_spans holds 2*n_matches int64_t values, (start,end) of
* the whole match for each match found, in order. */
void tc_finditer(const char *desc, const char *pat, int flags,
const char *subj, size_t subjlen,
int exp_n_matches, const long long *exp_spans);
void tc_sub(const char *desc, const char *pat, int flags,
const char *subj, size_t subjlen, const char *repl, int count,
const char *exp_result, int exp_n);
/* split: texts[i] == NULL means that list element is None in Python. */
void tc_split(const char *desc, const char *pat, int flags,
const char *subj, size_t subjlen, int maxsplit,
int n_items, const char *const *texts);
void harness_report(void);
#endif
+10
View File
@@ -0,0 +1,10 @@
#include "harness.h"
#include <stdio.h>
extern void run_generated_tests(void);
int main(void) {
run_generated_tests();
harness_report();
return tc_fail ? 1 : 0;
}