real-regex
Linear-time, ReDoS-safe regular expressions with bounded lookarounds — Rust bindings to the REAL C++ engine.
Every pattern that compiles matches in time linear in the input. There is no backtracking, so no
catastrophic blow-up: the pathological (a+)+b that hangs a backtracking engine runs in microseconds here.
The engine is strict by design — a construct it cannot run linearly (a backreference, an unbounded
lookaround) is rejected at compile time, never silently made non-linear.
use Regex;
let re = new.unwrap;
for caps in re.captures_iter
Unlike RE2 and the regex crate, REAL supports bounded lookahead and lookbehind — in linear time. The
engine and this crate share one calendar version.
The API
The crate mirrors the regex crate: Regex with find / find_iter / captures
/ captures_iter / is_match / replace / replace_all / replacen (with $-templates, NoExpand, and
closures) / split / splitn; Match (spans) and indexable Captures (caps[0], caps["name"]);
RegexBuilder (case-insensitive, multi-line, unicode(false), …); and a bytes module over &[u8]. Every
method is verified against the regex crate by a differential test suite.
Divergences from the regex crate
A drop-in mirrors semantics, not just signatures. The known differences:
-
Empty-match iteration — resolved. REAL's engine follows Python
re's rule (3.7+, which keeps empty matches and, after one, re-tries a non-empty match at the same spot); the crate instead drives the search by position, exactly asregex-automata'sutil::iter::Searcherdoes — find the leftmost match from a position, advance to its end, and step one codepoint past an empty match adjacent to the previous end before re-searching. Driving (not filtering re's stream) is necessary because rust visits positions re never does ((?:|ab)*on "abab": rust yields empties at 1 and 3). To keep this free for the common case, the wrapper stays on the cheap engine iterator until the first empty match, then switches to driving — so patterns that never match empty pay nothing.find_iter/split/replace_allmatch theregexcrate. -
$anchor — resolved. Pythonre's$(no multiline) matches at the end or just before a final\n; rust's is end-only. The crate compiles every pattern with the engine'sdollar_endonlyflag, so$is end-only —a$on"a\n"finds nothing, likeregex.(?m)$(line-relative) is identical in both. -
\wand\ssemantics — CPython, not UTS#18. REAL defines\w(and\b, which inherits it) and\sthe way Pythonredoes — its contract — which is not the UTS#18 definition theregexcrate uses. Each difference is bidirectional (measured over all 1,112,064 scalars):code points regexmatches but REAL does not (UTS#18 ⊃)code points REAL matches but regexdoes not (CPython ⊃)\wmarks \p{M}— Mn 2020, Mc 468, Me 13; Join_Control ZWNJ/ZWJ (2); connectors\p{Pc}beyond_(9); Other_Alphabetic symbols\p{So}(130)numeric-other \p{No}— 915 (superscripts, subscripts, fractions: CPython'sstr.isalnum)\s— U+001C–U+001F(4, the file/group/record/unit separators — CPython'sstr.isspace)\d(both\p{Nd}) is identical. The\wdelta also flows through every word-boundary assertion —\b,\B, and the\</\>word-start/end extensions all use the same word-set — so\bétéor\<fooon text with a combining mark or a\p{No}will differ from theregexcrate the same way. These\w/\sdifferences are intentional (REAL matchesre, asserted by a REAL/redifferential and the 3.2M-case exhaustive); for byte-for-byteregexparity, use thefallbackfeature. The counts are reproducible with the committed probe (fuzz/unicode_probe/), which re-dumps them on any Unicode bump; the differential fuzzer skips a\w/\b/\</\s-family pattern whose text carries a delta code point (the delta set is computed from both engines, so it tracks the probe automatically), so the divergence does not read as a REAL bug. -
Case-insensitive folding — CPython, not simple CaseFolding. Under
(?i), REAL follows Pythonre's equivalences (viastr.upper/lower), so the Turkish dotless/dotted I fold with I/i:(?i)Imatches ı (U+0131) and İ (U+0130), and(?i)\p{Lu}therefore matches ı — exactly as stdlibredoes. Theregexcrate uses Unicode simple CaseFolding, which keeps ı apart. Two code points, one contract each — both correct. The differential fuzzer masks exactly this set (ICASE_FOLD_DELTAS, computed by asking both engines), the twin of the\w/\smask;fallbackgives byte-for-byte crate folding. -
A malformed
{…}— literal, not a quantifier. A{that does not open a strict{n}/{n,}/{n,m}(ASCII digits only) is a literal brace in REAL, matching Pythonre. Theregexcrate is whitespace-tolerant, so{ 2 }/{\n4\n}are quantifiers to it —${\n…}becomes$repeated (an empty match) where re/REAL find nothing. A legal parser-interpretation difference, both correct for their contract; the differential fuzzer skips a non-strict-brace pattern by form. -
Empty-alternation-branch loops — a three-way corner. For an empty-first-branch repetition (
(|a)*) underfind_iter's forced-non-empty step, REAL, theregexcrate, and Pythonreeach produce a different span sequence on"aa": REAL consumes maximally ((0,0)(0,2)(2,2)), the crate goes all-empty ((0,0)(1,1)(2,2)) or drops the trailing empty ((a|)*→ loses the final(2,2)), andresteps out through the empty branch ((0,0)(0,1)(1,1)(1,2)(2,2)). REAL's exact behaviour and why it is not "fixed" (a fix would rework the star-loop termination that underlies every quantifier) are pinned in the C++ divergences page (div_empty_first_branch_loop); the differential fuzzer skips the class by form (has_empty_alternation_branch), since the crate is not a reliable oracle for it — Pythonreis. A singlefind/capturesagrees. -
shortest_match— residual. REAL is leftmost-first (likeregex), but this returns the leftmost match's greedy end, whereasregexreturns the earliest position at which a match completes (a+on"aaa": REAL3,regex1). A true earliest-completion mode (afirst-acceptstop in the forward pass) is a planned engine follow-up; until then, useshortest_matchas anis_matchthat also reports where the leftmost match ends. -
Unicode property classes
\p{…}— General_Category, Script, Script_Extensions and the standard binary properties, natively.\p{L},\p{Lu},\p{Nd}, the groups\p{L}..\p{C},\p{sc=Greek}/\p{Script=Latin}/\p{scx=Grek}(short UAX24/ISO 15924 codes, long names,gc=/sc=/scx=prefixes, loose matching, negation\P{…}—scx=has no bare-name form, same as PCRE2), and the 63 standard binary properties (\p{Alphabetic},\p{White_Space},\p{Emoji}, no namespace of their own, same as PCRE2) run on REAL's linear engine —engine()reportsReal. Other UAX44 properties (Bidi_Class,Word_Break,Age, …) raiseError::Unsupported; enable thefallbackfeature andRegexBuilder::new(pat).fallback(true)to delegate those to theregexcrate (per pattern, forfeiting the linear-time guarantee —engine()reportsFallback). -
Class set notation — declined (rust-only syntax). Nested character classes (
[a[b]]= union) and the set operators&&/--/~~areregex-crate syntax; Pythonre— REAL's model — reads[as a literal inside a class, so the two would parse the same pattern differently. The crate declines these up front withError::Unsupported(never a silent mis-match); escaped forms ([\[]) and ordinary ranges stay accepted.fallbackdelegates them toregex. Planned alongside\p{}as drop-in-completeness features. -
RegexSet— offered (which-matched). Multi-pattern set matching:RegexSet::new/is_match/matches(bitset, construction order). Stage-1 is N independent walks with per-pattern early-exit — not a fused single-pass (that is a follow-up). Not the same as C++real::dfa(maximal-munch lexer). -
Bounded lookarounds — a positive divergence. REAL supports bounded lookahead
(?=…)/(?!…)and lookbehind(?<=…)/(?<!…)in linear time. Theregexcrate and RE2 support neither. This is a documented superset, not a gap. -
A known upstream
regexleftmost-first violation. REAL's differential fuzzer found a case where theregexcrate (1.12.x) is wrong and REAL agrees with Pythonre: onA|.AAover"\n#AA", the leftmost match is[1,4)(it begins with.), but the crate's reverse-suffix optimization skips it. Fixed upstream in rust-lang/regex#1373 (found by REAL's fuzzer); repro + analysis infuzz/known_rust_bugs/, and the differential fuzzer skips the class so it does not read as a REAL bug.
The fallback feature
= { = "…", = ["fallback"] }
Off by default (the crate stays strict and pulls no extra dependency). On, a pattern REAL cannot run
linearly can be delegated per pattern with RegexBuilder::new(pat).fallback(true); Regex::engine() returns
Engine::Fallback for it. Regex::new is always strict.
License
MIT.