Releases: RECHE23/real-regex
Release list
v2026.7.25
All five POSIX grammars now run on REAL's linear engine, the std::regex drop-in gains a per-operation
ReDoS-safety guarantee worded to hold, and a committed Fowler/AT&T conformance gate pins it — plus two
shipped bugs the measurement caught before this tag.
basic (BRE), awk, grep, and egrep join extended (shipped in 2026.7.24): a translatable pattern in
any of the five POSIX grammars is translated to REAL and run on the linear engine with leftmost-longest
(POSIX) bounds, instead of delegating to std::regex's backtracker. regex.posix_longest() reports it.
Each grammar's shape is honoured. BRE: \(/\) group and \{n\} quantify, while bare ( ) { } | + ? are
literals. awk: the C-escapes, where \b is backspace (0x08), not a word boundary — plus \n\t\r\f\v\a,
\/, \", and octal \ddd. grep (BRE) and egrep (ERE) read a newline as a top-level alternation of the
lines — which finally gives the BRE family a leftmost-longest acid it cannot otherwise express. A construct
only the backtracker runs — a backreference, an ECMAScript-ism, or a corner that is undefined in POSIX and
divergent across the std libraries (a medial ^/$, \]/\} outside a class, an empty alternation
branch) — declines to std::regex, never a silent wrong match.
The guarantee is worded to the one claim that cannot be gamed:
No accepted pattern can make matching super-linear — across all five POSIX grammars. Under the default
policy::strict, every accepted pattern executes eachregex_search/regex_matchin time linear in the
input. A pattern the linear engine cannot represent is rejected, never silently made non-linear;
policy::fallbackdelegates it tostd::regex(forfeiting the guarantee).
regex_replaceand the iterators compose up to O(n) such operations, so their worst-case total is
quadratic — inherent to repeated scanning on any linear engine (RE2 and the Rustregexcrate
included), not a REAL limitation — but never exponential when running on REAL. A nullable pattern's
replace/iteration delegates tostd::regex(correct results, not ReDoS-safe).
So (a+)+b under an egrep grammar runs regex_search in microseconds where a std::regex drop-in blows
up exponentially. The quadratic-iteration cost is self-disclosed, so the claim has no gotcha left.
The three vendored testregex corpora (basic / nullsubexpr / repetition) run through real::compat vs the
local std::regex as a gate step, arbitrated three ways — REAL, std, and the corpus's own POSIX-longest
expectation. 403 cases are perfect (REAL == std == Fowler); the hard invariants (REAL agrees with std on
every routed case, never over-accepts, the perfect count, and the per-file parsed-case counts — a
"no silent caps" pin) are enforced on both libc++ and libstdc++. It carries a reference AT&T decoder (the
i flag, the SAME directive, NULL/NIL, \xHH and octal escapes) that replaces a lossy shared parse.
- A leading
]in a POSIX bracket expression ([]],[^]b]) is a literal member — it was emitted as a
bare], which REAL reads as an empty class, so the pattern never matched. Shipped forextendedin
2026.7.24; the conformance gate's "REAL alone diverges" bucket caught it before this tag. - An empty alternation branch (
(|a)*,ab|) is rejected bystd::regexin the POSIX grammars, but was
translated and routed to REAL — so compat over-accepted vs its own std. It now declines, staying≡ std.
v2026.7.24
POSIX Extended (ERE) grammar moves onto the linear engine — the compat layer's first ReDoS hole closed for
extended, on every operation — and a P1 parity bug shipped since 2026.7.3 is fixed: mixed character classes
now union their members regardless of order.
The std::regex compat layer routed every POSIX grammar to std::regex's backtracker — the one non-linear path
left in the library. extended (ERE) now closes it. A translatable ERE pattern is translated to REAL and run on
the linear engine with leftmost-longest bounds — the POSIX semantics — instead of delegating. This holds for
every operation on a translated non-nullable ERE: regex_search / regex_match run on search_longest,
and regex_replace / the iterators run on find_iter_longest. So (a+)+b under an ERE grammar matches in linear
time where std::regex would blow up — verified: 200k a iterates and replaces in ~9 ms.
regex.posix_longest() reports the routing. POSIX bracket classes ([[:alpha:]] … [[:xdigit:]]) become their
C-locale ASCII ranges. The translator declines — falling back to std::regex, never a silent wrong-match — on the
constructs the two grammars read differently: an ECMAScript shorthand (\d \w \s \b, undefined or literal in
ERE), an ambiguous {, an unknown or collating [[:…:]] / [.x.] / [=x=]. A nullable ERE (x*) keeps its
search/match on REAL but delegates replace/iterate to std (the empty-match traversal differs, exactly as on
the ECMAScript path), with POSIX-correct bounds. Captures are the winning thread's, not POSIX submatch (documented).
The other POSIX grammars — basic, awk, grep, egrep — still delegate; they are next.
In a text-mode character class, a non-ASCII member — a literal, a range, or a second predicate — that followed
a code-point predicate (\w \d \s \p{…}) was silently lost. [\dЩ] did not match Щ while [Щ\d] did;
[\dа-я], [\w\W] (which dropped U+0382), and [\p{L}\p{N}] (which dropped the Nd digits) all under-matched.
The members accumulated in parse order, but the match-time class binary-searches them and so requires them sorted —
a member sorting below the predicate's ranges landed out of order and was missed. Members now union regardless of
order (Python re and the regex crate co-arbitrated the fix). This closes a parity gap widened by the \p{}
in-class work of 2026.7.23; anyone matching mixed classes on 2026.7.3–2026.7.23 should update.
The POSIX bounds are backed by a new, opt-in match_semantics { first, longest }. The default (leftmost-first) is
untouched and byte-identical. The longest mode is reachable directly via search_longest(text[, pos, endpos]) and
its iteration twin find_iter_longest(text[, pos, endpos]). Among matches at the leftmost start it returns the
longest (a lazy quantifier behaves greedily); captures are the winning thread's, not POSIX submatch. These are a
prototype for the match_semantics arc and are not yet a stable API. The default first-match fast paths are
bypassed under longest; first mode keeps them, proven byte-identical by the exhaustive and 4-D matrix gates.
v2026.7.23
The \p{…} arc — Unicode property classes land on all three surfaces, linear like everything else, with the
one contract subtlety they surfaced documented rather than papered over. And the front-page comparison table
finally splits RE2 from the rust crate.
\p{L}, \p{Lu}, \p{Nd}, the groups \p{L}..\p{C}, and \p{sc=Greek} / \p{Script=Latin} — with short
codes, the UCD long names (\p{Letter}), gc= / sc= prefixes, loose matching, the single-letter form \pL,
and negation \P{…} — now compile and match on the C++ engine, the Python binding, and the Rust crate. They run
as the same klass_cp code-point predicate as \w, so they are linear as everything else: \p{L}+ measures
identical to \w+, \p{Nd}+ to \d+. They compose inside a class ([\p{L}\d_], [^\p{L}], the double
negation [^\P{L}]) and fold under IGNORECASE by the ordinary class pipeline. Accepting \p{…} is a superset
of stdlib re, which rejects \p outright — it cannot break re-compatible code. In the Rust binding they run
natively (engine() reports Real); a binary property like \p{Alphabetic}, not yet tabulated, still delegates
via the fallback feature.
Verifying the icase fold surfaced one code point (two, with İ) where the engines legitimately differ: under
(?i), REAL folds the Turkish dotless/dotted I (U+0131 / U+0130) with I/i — Python re's equivalence via
str.upper/lower, verified against stdlib (re.fullmatch("(?i)I", "ı") is True) — whereas RE2 and the rust
crate use Unicode simple CaseFolding, which keeps ı apart. One code point, both engines correct for their
contract. It is general (not \p-specific), documented on both the divergences page and the Rust README, and the
differential fuzzer masks exactly that set (its ICASE_FOLD_DELTAS, computed by asking both engines), the twin
of the existing \w/\s mask.
The General_Category tables are built from, and exhaustively re-checked against, unicodedata; the Script tables
are parsed from a committed Unicode 16.0.0 Scripts.txt and cross-checked against the regex module — each a
permanent regen guard over all 1.1M scalars. The parse → merge → match path is proven equal to the rust regex
crate exhaustively (0 divergence, both Unicode 16.0.0). The whole feature cost +7.5% compile and 0 throughput.
The front-page table splits RE2 and the rust regex crate — the numbers outgrew the adjectives. RE2 is a
uniform 3–9× slower; the rust crate is mixed (REAL leads class scans and dense captures, the crate leads
straight-line literals and word-boundary, no-match is near parity — the per-line duel is in the benchmarks), and
std::regex's cell now quantifies the ReDoS gap (4.1 s where REAL takes 0.5 ms) instead of an adjective.
v2026.7.22
Closing the rust gap — the inner-literal prefilter takes the biggest gap line to parity, the real-regex
crate's find_iter outruns the regex crate, and a third differential fuzzer (against that crate) lands with
its first harvest: an upstream bug, a documented \w divergence, and a \s fix that makes our own drop-in
claim true.
A pattern whose match does not begin with a literal — the date \d{4}-\d{2}-\d{2} — was the duel's worst
line: REAL scanned every position a digit could start a match at, where the regex crate memmem'd the rare -
and skipped the rest. REAL now does the same: it extracts a required inner literal, scans for it, reverse-
matches the prefix to the match start, and confirms forward.
- The date no-match line went from ~201× the
regexcrate to ~1.6×. A haystack the literal never appears
in costs a single memmem; sparse-match haystacks improve by 30–100×; dense matches sit at parity — the
per-candidate confirm reuses the forward-DFA + one-pass extract, and its setup is amortized per-regex, gated
below a measured size so a small haystack never regresses. - Byte-identical to the core, proven on the 3.2M-case exhaustive with the route on. The leftmost-correctness
contract (the reverse bound advances only on a yield; the forward backstop is the linearity guard) is the
arc's lesson. - A committed 4-D veto matrix (
make matrix-gate, infull-local-gate) now screens pattern × size ×
match/no-match × density on every hot-path change — because a bench that measures one slice hides a
regression in another.
find_iter now outruns the regex crate on span throughput (the wrapper reuses one span buffer and takes
a span-0 fast path — no per-match allocation). The crate also gained a native criterion bench and a
rebar-protocol runner.
The crate now has its own differential fuzzer — the third oracle after Python re and std::regex. Its first
runs already paid off:
- An upstream
regex-crate bug, found by REAL and fixed upstream in
rust-lang/regex#1373: with a top-level alternation, its
literal prefilter can resume past a leftmost match beginning in another branch. REAL agrees with Pythonre;
the fuzzer skips the class (with a depth-aware detector) so it does not read as a REAL bug. - A documented
\wdivergence. REAL's\wfollows Pythonre(its contract), not UTS#18:regexmatches
marks / Join_Control / connector-Pc / Other_Alphabetic (2642 code points) thatredoes not;rematches
\p{No}(915) thatregexdoes not. Recorded in the crate's Divergences table, with a reproducible
category-annotated probe (fuzz/unicode_probe/). - A
\sfix — our own drop-in claim, made true. The same cross-engine probe surfaced that REAL's\s
rejectedU+001C–U+001F, which Pythonrematches (str.isspace) — a pre-existing divergence from our
primary contract, hidden because no fuzzer had fed a control char to\s.\snow matchesre; a
drift-guard test pins the hand-written ASCII shorthand sets to the oracle-generated tables so the class
cannot recur, and the fuzzers' text alphabets gained control-char coverage.
No behaviour change for existing patterns beyond the \s correction toward re. The crate's earlier semantic
fixes ride in this train: $ is end-only (the engine's dollar_endonly flag, like regex), and empty-match
iteration is driven by position (as regex-automata does). The inner-literal route is transparent (the
exhaustive and its routed==core differential assert it). All five published-version surfaces stay CalVer-locked
in step.
v2026.7.21
Bindings, fortified — the binding surfaces are now guarded like the engine, and the crate publish is automated.
A post-audit hardening release (the engine is unchanged; this is CI, tests and docs):
- The crate publish is automated. release.yml now publishes the real-regex crate to crates.io after the
wheels, gated on a crates-io environment and idempotent (a re-run of an already-published version is a
no-op, not a failure). It was a manual step until now. - A cross-platform bindings matrix. The C shim and the Rust crate are built and tested on macOS (ARM) and
Windows (MSVC), not only Linux — a platform issue in the C ABI (the kind of MSVC deprecation that surfaced
during the hotfix) is now caught in CI, not by a user. - The C ABI surface is netted. The shim runs under ASan/UBSan and is fuzzed (fuzz-capi), and its coverage
is visible; the coverage floor measures the engine's reachable logic, with the shim's unreachable
FFI-safety catch-alls guarded by the sanitizers and fuzzer instead. - The perimeters are written. SECURITY.md documents the three published surfaces and the fallback nuance;
MISRA.md documents that the C ABI shim is a scoped, assumed deviation; the CMake position (bindings/c is
source-only) is decided and recorded.
Small engine hygiene rides along (a documented hot-path function, a redundant parallel vector removed).
v2026.7.20: Hotfix — FFI safety hardening for the Rust binding.
Hotfix — FFI safety hardening for the Rust binding.
An audit of the freshly published Rust crate found two real FFI-safety bugs in the C ABI shim. This release
fixes them forward (the engine and the Python binding are unchanged):
- The match iterator could dereference a null cursor. real_iter_next now has a full try/catch and a null
guard (contract 1=match / 0=end / -1=error), and the Rust wrapper checks the cursor and panics on an
engine error instead of risking undefined behaviour — a linear search is infallible after compile, so an
abort is surfaced, never silently turned into "no match". - The fallback feature classified unsupported vs syntax errors by matching the engine's message text. The
engine now tags each rejection with a stable kind, the C ABI exposes it as a code, and the binding
classifies on the code — so a reworded message can never silently stop delegating a pattern.
Also in this release: the bytes:: module reaches full regex::bytes parity (splitn / is_match_at /
captures_at / shortest_match), the crate's packaging dry-run is now enforced in CI, and a documentation
cleanup (the repository map and tier list catch up with the recent moves).
Users of the Rust crate on 2026.7.18 / 2026.7.19 should upgrade.
v2026.7.19: The Rust drop-in — real-regex now mirrors the regex crate.
The Rust drop-in — real-regex now mirrors the regex crate.
The real-regex crate grows from a thin binding into a drop-in-shaped mirror of the
regex crate, engine unchanged (this is a binding-and-docs release):
- The full query API — find / find_iter / captures / captures_iter / captures_len / capture_names / is_match
/ shortest_match, Match (spans) and indexable Captures (caps[0], caps["name"]), find_at. - replace / replace_all / replacen with the Replacer trait ($-templates, NoExpand, closures), split / splitn,
and RegexBuilder (case-insensitive, multi-line, unicode(false), …). - A bytes:: module mirroring regex::bytes over &[u8] (which need not be valid UTF-8).
- A drop-in mirrors semantics: the empty-match iteration rule is adapted to rust's (an empty match adjacent
to the previous match end is skipped), so find_iter / split / replace_all match the regex crate. - A typed Error (Unsupported vs Syntax) whose hint names the fix, and the opt-in
fallbackfeature: with it,
RegexBuilder::fallback(true) delegates a pattern REAL cannot run linearly (\p{...}, a backreference) to the
regex crate, per pattern, observably via engine() — forfeiting the linear-time guarantee only for those.
Every method is verified against the regex crate by a differential suite, in both feature configurations.
Bounded lookarounds remain REAL's positive divergence — a superset the regex crate and RE2 cannot offer.
v2026.7.18
Packaging: the real-regex Rust crate is born — the engine, callable from Rust, linear-time with bounded
lookarounds.
real-regex publishes to crates.io: a safe wrapper (Regex::new / find_iter / Match) over the engine's C ABI,
with the same ReDoS-safe, linear-time guarantee and the bounded lookarounds RE2 and the regex crate cannot
offer. Its tests agree span-for-span and group-for-group with the regex crate. Same calendar version as the
C++ and Python releases, single-sourced through make version-check.
Also: the crate's publish flow is now gated by a compiling dry-run — make crate-publish-check vendors the
engine into the crate and runs cargo publish --dry-run, which builds the packaged tarball (what
cargo package --list does not), proving the crate compiles standalone before it ships.
v2026.7.17: The numbers, refreshed — and REAL out-runs PCRE2's JIT on class scans.
The numbers, refreshed — and REAL out-runs PCRE2's JIT on class scans.
The headline: on the devbox, with the PCRE2 JIT verified on (PCRE2_CONFIG_JIT = 1), REAL now out-runs
PCRE2-JIT on class scans — [a-z]+ 1.94×, [0-9]+ 1.73×, [^,]+ 1.11×, level on the rare-byte date —
where the earlier baseline trailed it. Every benchmark section is re-measured on this version under one
unified Conditions banner, non-cherry-picked (the losses show: PCRE2-JIT still leads straight-line
literals/alternation; the bounded lookahead is honestly slow, the cost of doing it in linear time). Still
5.6–27.6× over std::regex and 1.1–8.6× over RE2; the Python binding is a 2.06× geomean over re, with the
capture-dense email case flipped from a past loss to a win. A benchmark-harness bug was fixed along the way —
the REAL-vs-rust duel now compares rust's captures_iter (both engines extracting groups), and REAL beats it
3.3–6.1× on class/digit/field scans.
A Rust crate is born. real-regex — a safe wrapper over a new C ABI shim (bindings/c/), with the same
linear-time, ReDoS-safe guarantee and bounded lookarounds RE2 and the regex crate cannot offer. Its tests
agree span-for-span and group-for-group with the regex crate. It carries the same calendar version (CalVer
is valid SemVer), single-sourced through make version-check; the first cargo publish is manual, after this
release is live.
Structure: the Python binding moved under bindings/python/, so all language bindings live in one place
(bindings/: c/, python/, rust/). No API change — the wheel, get_include(), and every import are unchanged.
v2026.7.16
A strict drop-in policy — reject, don't silently fall back to backtracking.
The std::regex and re drop-ins used to differ: the Python binding rejected an unsupported pattern, while
real::compat silently fell back to std::regex — reintroducing the ReDoS the library exists to avoid, on
exactly the patterns a caller can least audit. Both are now strict by default.
- A pattern the linear engine cannot represent (a backreference, a conditional, an unbounded lookaround) is
rejected, not silently delegated — so every accepted pattern is a linear-time, ReDoS-safe guarantee.
In C++ that is aregex_errorwitherror_complexityand a REAL-identifiable message; in Python, a
real.error. A pattern invalid for both engines still reports the standard library's own error code,
so a syntax error stays a syntax error — a true drop-in there. - Fallback is opt-in and observable.
real::compat::regex(pat, flags, policy::fallback)in C++, and
real.compile(pat, fallback=True)(or module-widereal.fallback = True) in Python, restore delegation
tostd::regex/refor ineligible patterns — forfeiting the linear-time guarantee for those.uses_real()
/uses_fallback()/policy()(C++) andPattern.engine(Python,"real"or"re") always tell you
which backend backs a pattern.
Also: the dev tooling is consolidated under tools/ (the Unicode-table codegen joins the layering check).
This changes the compat default while it is still pre-adoption; migrate a caller that wants the old behaviour
by passing policy::fallback / fallback=True. See COMPATIBILITY.md's "The drop-in policy".