Two public entry points, one shared front end:
Regex[pattern, flags]— a single pattern, matched with Python-style leftmost-first semantics (flagsdefaults to none; it is compiled as a leading inline group,Regex._pat).RegexSet[patterns]— a multi-pattern database in the shape of Intel Hyperscan: scan once, report every pattern that matches and where.
Everything — parsing, NFA construction, determinization, table generation,
engine selection — happens at compile time, inside comptime field
initializers. An invalid pattern is a compile error, not a runtime
exception, and the emitted binary contains the automaton as constant data.
pattern string
│
▼
parser.mojo recursive descent; grammar is
│ alternation > concat > quantified > atom.
│ Extracts inline flags ((?i)(?m)(?s)(?x)), named groups,
│ POSIX classes, (?# comments).
▼
ast.mojo flat pool of ASTNode + CharSet, indexed by integer
│ rather than pointer (cache locality, and comptime-
│ friendly — the interpreter handles Lists of PODs well).
▼
nfa.mojo Thompson's construction into NFAFragment values.
│ Records the capability flags engine selection reads:
│ can_use_dfa, has_lazy, start_anchor.
▼
engine selection (comptime if — see below)
Flag-dependent behaviour is baked into the states, never checked at
runtime: (?m) picks BOL_MULTILINE over BOL, (?i) turns a literal
into a two-member charset, (?s) turns . into a full-byte charset.
\A and \z lower to plain BOL/EOL and deliberately skip the
multiline promotion, which is the only difference between them and ^/$.
CharSet stores sorted non-overlapping ranges plus a 256-bit SIMD bitmap
for O(1) byte membership. MatchResult stores a flat slots list — pairs
of [start, end] byte offsets, one pair per group, group 0 being the whole
match.
Every engine here is byte-oriented — that is what makes the DFA tables and
the SIMD kernels work at all. (?u) does not change that; it changes what
the compiler emits. A codepoint range becomes an alternation of byte-range
SEQUENCES, so [α-ω] is one automaton over bytes rather than a byte class
that would happily match a lone continuation byte:
[α-ω] -> CE B1-BF (U+03B1 .. U+03BF)
| CF 80-89 (U+03C0 .. U+03C9)
Two things make this affordable for the large Unicode classes:
- The minimal byte automaton (
_utf8_trie_fragmentinnfa.mojo). One chain per sequence is the obvious construction and it does not scale:\p{L}is 836 sequences ≈ 3600 states behind an 836-way SPLIT chain, so every epsilon closure walks all 836 alternatives. The sequences arrive sorted, so a prefix trie is built by comparing each with its predecessor (at every depth two sequences' ranges are either equal or disjoint and ascending); trie nodes are then hash-consed children-first, merging equal suffixes — UTF-8 repeats itself in its tails, every final80-BFis one node — which yields the minimal DFA of that acyclic language; and each node emits one multi-range CHARSET per distinct target.\p{L}is 968 states,\p{Ll}246 (from 1465 with prefix factoring alone). A counted repeat multiplies the class, so this is also what brought(?u)\p{L}{8,13}from 8.5 minutes of compile time to about 2.5 (a from-source-I .build, 2026-09-25). - Surrogates are excluded. U+D800..U+DFFF are not Unicode scalar values
and have no UTF-8 encoding, so
utf8_rangescuts the block out of any range that spans it (\p{Any},\p{C},.under DOTALL). Emitting them would build an automaton acceptingED A0 80— ill-formed UTF-8.
Case folding. (?iu) is CPython's str-pattern IGNORECASE: a literal
matches its whole case orbit (s/S/ſ, k/K/Kelvin sign, Cyrillic
and Greek pairs), and a class is closed under orbits before any negation.
The orbits are generated from CPython itself (_sre.unicode_tolower plus
re._casefix) by tools/gen_case_tables.py into case_tables.mojo, and
looked up at comptime through SIMD columns. Backreferences are the
exception, as in Python: they compare by simple lowercase
(backref_icase_unicode), so ſ does not repeat an s. Without (?u)
only ASCII letters fold.
Property tables are generated from the UCD (tools/gen_unicode_tables.py
→ unicode_tables.mojo, Unicode 17.0) and checked in, so a build needs no
Python. They cover every general category and 43 scripts. Everything comes
from ONE source (the regex module): deriving categories from CPython's
unicodedata and scripts from regex was tried first and is a trap — they
are independent UCD copies on independent release cadences, and were 15.1.0
vs 17.0 here, disagreeing on 24 ranges of \p{L} alone. The generator
cross-checks each major category against the union of its subcategories.
Big classes still cost real compile time (tens of seconds for \p{L}),
because all the automaton construction is comptime work. Lookbehind is refused in UTF-8
mode: it needs a fixed byte width and a codepoint class spans 1-4.
Selected fastest-first at compile time:
| Condition | Engine | File |
|---|---|---|
| pattern is a literal string | SIMD literal scan | simd_scan.mojo |
prefix + .* + suffix |
sandwich (startswith/endswith) | optimize.mojo |
| alternation of literals | Teddy nibble shuffles | teddy.mojo |
| DFA fits a shuffle tier (16/32/64 states on NEON, 16 on x86) | Sheng | sheng.mojo |
can_use_dfa, ≤ EDFA_STATE_CAP |
eager comptime DFA (match()) |
static_dfa.mojo |
| same, search-family verbs | leftmost-first DFA + reverse DFA | static_lfdfa.mojo, static_rdfa.mojo |
can_use_dfa, CLASSIC table over EDFA_STATE_CAP (or the attempt past EDFA_WORK_BUDGET member-class visits) |
lazy DFA for match(); its search verbs on the lazy leftmost-first DFA + lazy reverse DFA when the pattern has no anchors or lookaround |
dfa.mojo, lazy_lf.mojo |
| captures, one-pass NFA with an alternation loop | one-pass DFA: match(), and the span confirm of the lane below (_use_onepass) |
onepass.mojo |
| captures, same shape, search-family verbs | DFA-bounded span + backtracker on the span (_use_dfa_span) |
engine.mojo |
backrefs / lookaround / captures (match(), not one-pass); also a greedy pattern whose classic table fits but whose leftmost-first table overflowed |
specialized backtracker | backtrack.mojo |
| backtracker budget exhausted | Pike VM | executor.mojo |
| backreference pattern (unbudgeted) whose walk exhausts the stack bound | heap-stack backtracker (heapbt_match) — the Pike VM has no BACKREF arm |
executor.mojo |
The eager DFA is the interesting one. Subset construction runs in the
comptime interpreter over the comptime NFA; byte equivalence classes bound
the per-state work; the transition table and per-state flag bytes
materialize as constant data. The runtime engine is a pure table walk with
no lazy construction and no fallible path. Three structural passes run over
the result, in this order: Hopcroft minimization merges states no input
can tell apart (subset construction separates state sets, not languages,
so cat|cot|cut|cit keeps four tails where one suffices); match states are
then permuted to ids [0, num_match_states) so the per-byte match test is
an integer compare; and states that self-loop on all but a few bytes are
accelerated — the walker SIMD-scans to the next exit byte instead of
stepping the table. Minimization comes first because the other two key off
final state ids, and because a self-loop is often only visible once the
duplicate states splitting it are merged.
The walker itself (_edfa_walk_from) steps a second, premultiplied
copy of the table (edfa_pm_bytes, tables up to 128 states): each entry
is its target's row as a byte offset in UInt16 (id << 9,
EDFA_PM_DEAD for a dead transition), so a step is one register-offset
load from row(byte) + state — the byte half is computed ahead from the
input, and the state chain carries no shift-add (sherlock/quotes
287 → 244 µs; the same encoding took a third off the lazy walker below). Two more skips ride the same walk. A ladder
(edfa_ladders) is a chain of at least LADDER_MIN (16) plain states
that each move to the next on one byte class and leave it on the rest —
the counting states of ["'][^"']{0,30}[?!.]["'] after the opening
quote — and from rung i one SIMD class scan of the next L - i bytes
finds the exit byte, or proves the run outlasts the chain, in place of up
to L - i table steps (sherlock/quotes 206 → 159 µs). And acceleration is adaptive: a walk whose
skips average under ACCEL_MIN_AVG bytes after ACCEL_PROBATION visits
(prose, where the restart state of [a-zA-Z]+x exits on every letter)
leaves for the plain loop, and a caller that walks again starts there;
ladder scans keep their own tally. The two loops are one function body
unrolled twice by a comptime for over the phase, not a call from one
instantiation to the other (see Comptime realities: a compiler bug).
That table answers match() — Python's fullmatch, a language-membership
question — and nothing else. Its states are sets, so a walk over it ends
leftmost-longest, and Python's search is leftmost-first: a|ab on
"ab" is a, <.*?> stops at the first >. The search-family verbs run a
second table, regex-automata's construction: a state is a priority-ORDERED
list of NFA states (DFS of the epsilon closure, a SPLIT's out1 before its
out2), a transition whose closure reaches MATCH drops every lower-priority
thread, and a restart bit re-adds the start closure as the lowest-priority
threads after every byte — the unanchored scan, folded in the way the set
lane folds its restart, and cleared by truncation so the walk terminates at
the leftmost-first end. The walker is the same table walk in different
start states; the same minimization, permutation, acceleration and Sheng
masks apply, and the restart-only state self-loops on everything outside
the first-byte set, so the acceleration scan is the candidate prefilter.
Where the match starts is then recovered by a reverse DFA — the
single-pattern form of the set lane's start-of-match automaton — walking
leftward from the end, never below the previous match end. Two walks per
match, both linear; the old lane's per-position anchored attempts were
quadratic on [a-z]+x over a long class run, and its backtracker re-run
for the leftmost-first end is gone from this lane.
The reverse walk is a second pass over every match, and on a short match
it is most of the per-match cost, so the lane tries the first prefilter
candidate anchored first whenever a cheap anchored engine exists for
the shape: the classic table when its leftmost-longest end is provably
the leftmost-first end (one greedy loop at most — [a-z]+x, .*x), the
specialized backtracker for a lazy pattern whose loops are all simple
(<.*?>: a byte compare and one SIMD class scan). A success is the match
with its start known — no reverse walk; a failure proves nothing starts
at that candidate and the unanchored scan takes over from the next one.
The wasted walk is bounded by the scan's own walk over the same bytes (the
scan keeps the failed attempt's threads alive, at top priority, until
they die), so the lane stays linear; a.*?b over a 64 KB run with no b
is 3.7 µs here against 2.3 s on the backtracker alone. One attempt per
match, never one per candidate. Every materialized table holds its state
ids in the narrowest integer type that fits them (edfa_id_dtype:
Int8, Int16 or Int32 — the table is the walk's hot data) and is
travels as a comptime string literal (static_bytes.mojo): a materialized
Array global costs O(n²) in the MLIR→LLVM translation (one folded
insertvalue per cell, ~6 s per 128-state table), while a !kgen.string
lowers to one static c"..." global that the walkers read through a
bitcast pointer — the same load, no per-call copy. EDFA_TABLE_MIN_BYTES
(1 KB) still pads the element count.
With a candidate scanner, the leftmost-first walk also stops when it
falls back to a bare restart state with nothing matched
(Regex._lf_restart_stops) and resumes from the scanner's next
candidate — Rust regex's prefilter at the start state. The scanner is a
literal filter, or a first-byte class rare enough in text
(LF_STOP_MAX_WEIGHT by the rank table: quotes and whitespace, not
letters) that stopping beats walking on; a letter-led class re-entered
the walk at every word and measured 3.4x slower.
A pattern with no start-anchored scanner may still carry a required
inner literal — a byte run every match must contain, sitting after an
unbounded loop where no prefix scanner can see it (\w+\.txt must
contain ".txt", [a-z]+://[^ ]+ must contain "://"). extract_inner_literal
(optimize.mojo) walks the NFA's mandatory spine at comptime — literal
states extend a run, zero-width states keep it open, loops make the
preceding gap unbounded, alternations contribute min/max over both arms —
and keeps the rarest run of ≥ 2 bytes that is not at fixed offset 0 (the
prefix scanners' territory). When the leftmost-first lane has no filter
prefix and no Teddy prefix (Regex._use_rev_literal, Rust regex's
ReverseSuffix/ReverseInner), _lf_next_match memmems the literal
(simd_find_literal_rare, the same two-rarest-probe Muła kernel the
filter-prefix scanner delegates to) before anything else: no occurrence
at or after pos + min_offset is proof of no match — one SIMD pass
answers the call (the reverse_suffix_search_64KB /
reverse_inner_search_64KB rows measure ~40x on miss-heavy 64 KB) — and
when the pre-literal gap is comptime-bounded ([ab]{0,3}foo: at most 3 bytes)
the candidate pipeline starts at lit_pos - max_offset.
With an unbounded gap (or a bounded one, when safe) the lane walks
leftward from each occurrence over a reverse DFA of the pattern's part
before the literal (prefix_nfa → Regex._prdfa, Rust regex's
ReverseInner), and confirms the start it finds with an anchored probe on
the classic table. Two guards keep that linear. rev_inner_safe (Rust's
has_no_earlier_match) admits a pattern only when an earlier match
cannot use a later occurrence as its literal: some literal byte is one
no prefix state consumes ([a-z]+://), or a separator class disjoint
from the literal and the rest of the prefix sits right before the
literal (\w+\s+Holmes) or starts the match (\s[a-zA-Z]{0,12}ing\s).
And a later occurrence's reverse walk stops at the previous occurrence,
its probe must start past the last failed probe's end (Rust's
min_pre_start), and an inconclusive walk hands the call to the plain
scan. A confirmed start is the match start: when the classic table's end
is provably the leftmost-first end it is returned as is, otherwise one
forward leftmost-first walk from the start finds the end — no reverse
walk. The pivot prefilter may coexist; the literal test simply runs first.
A search also fails fast on a required byte — a byte every match
contains — unless another scan already fails fast on it (the pure-literal
scan, Teddy, the Teddy prefix, the pivot; a filter prefix only covers its
own bytes, so (a|aa)+b still checks for b). Every CHAR state of that
byte sits inside a forced chain of CHAR states, and the set of those
chains is required too (extract_required_literals): for the AWS-key
pattern that is ASIA|AKIA|AROA|AIDA rather than the A in every other
line, checked with one Teddy scan. The upgrade is skipped for a single
chain, and where the reverse-literal memmem runs anyway: a set scan over
a miss would only repeat it.
Capture groups do not keep a pattern off these tables — SAVE states are
epsilon to every determinizer — only backreferences and lookaround do. A
capture pattern of the admitted shape runs its search-family verbs on the
DFA-bounded capture lane (Regex._use_dfa_span, regex-automata's
"Core" strategy): the same forward scan and reverse walk give the span,
and the specialized backtracker then runs anchored at the start and pinned
to end at the end (anchored_end + end_at, over the whole input so $
and \b see the real neighbours) to fill the slots. Its first success on
that span is Python's capture assignment: the span is the leftmost-first
match, every path before it in NFA priority order fails to reach MATCH at
all, and the backtracker explores in that order — so the pinned walk costs
what an unpinned walk from the start costs and never reaches the
lower-priority assignments with the same span. The lane's miss path is the
DFA scan alone (the backtracker-per-candidate lane re-ran (\w+)@(\w+)\.com
from every word byte). On this lane the anchored-first attempt on the
backtracker is made for every shape, not only lazy ones: a success carries
the slots, so a true first candidate costs one backtracker pass — the old
lane's cost — and only a false one pays the scan, the reverse walk and the
confirm (without the attempt the short-input findall rows measured
1.2-1.7x). The lane's candidate comes from the literal/pivot prefilters or,
failing those, a SIMD first-byte class scan. split and a replace
without backreferences skip the fill. When the backtracker gives
up on a span (budget or depth) the Pike VM runs on exactly that span with
the same end pin, never on the whole input — and the walk latches: every
later span of the same call goes straight to that VM, built once per
walk, so a pattern whose confirm always gives up ((a*)*b) pays one
budget per call, not one per match. match() with captures stays on the
backtracker unless the pattern is one-pass (next paragraph).
One-pass DFA (onepass.mojo, Regex._use_onepass, regex-automata's
onepass): when at most one NFA thread can consume each byte, the path
through the NFA for any input is unique, and the capture slots can be
written during a single forward table walk with no backtracking. A DFA
state is one NFA state (a transition target) plus a position context
(start / after \n / mid-line, split by the look-behind word class for
\b patterns); expanding it walks the epsilon closure in priority order
carrying the SAVE slots passed, and every consuming member contributes
transitions tagged with those slot writes. The pattern is NOT one-pass
when a later member wants a byte class an earlier one already claimed
with a different target or slot set ((a|ab)(c|bcd), (a*)(a*),
(a)\b|(a)), or when it carries a backreference or lookaround, or when
the automaton exceeds ONEPASS_STATE_CAP (128) / 63 slots. Unlike
regex-automata the closure does not stop at MATCH: match() is
fullmatch (language membership: (a*?) must fullmatch "aaa") and the
capture lane's span confirm already knows its end, so both walkers
(onepass_match, pinned to a known [start, end)) run to the pin and
require a match state there — the unique path's slot writes are then the
only assignment. BOL kinds resolve against the context at build time,
$ / (?m)$ / \b before a consuming state restrict its byte classes,
and only the conditions on the byte after the end remain as per-state
match flags checked once against the whole input.
The one-pass walk replaces the backtracker in exactly two places, both
gated on _use_onepass: match() (the whole input), and the capture
lane's span confirm (_span_fill_slots, over a span the forward/reverse
DFAs already found — exact and O(span), so a one-pass pattern's search
verbs never fall to the Pike VM on a pathological confirm). Selection is
by SHAPE, not validity alone (onepass_shape): the walk costs ~1.8 ns
per byte, so it is taken only where the backtracker is much slower — a
general loop (a cyclic SPLIT the backtracker runs by recursion) whose
body carries an ALTERNATION, where the backtracker re-tries an arm per
character ((?:(x)|y)+, (?:(x)|(y)|z)+, (a|b)*c, (?:(ab)|(cd))+):
4-9x faster at every length, and no SBT_BUDGET / SBT_STACK_BUDGET cliff. A
simple loop anywhere, or a general loop without a body alternation
(((a)(b))+, (?:([a-z])(\d))+), keeps the backtracker, whose SIMD
class runs and cheap short-body recursion win there. The build is its
own comptime field, read only where _use_onepass can hold (a capture
pattern of the alternation-loop shape), so simple shapes and
capture-free patterns never pay for it.
Word boundaries ride all three tables. \b needs the byte on both
sides, and a closure only knows the one behind it, so — regex-automata's
approach — a DFA state carries the look-behind: the word class of the
byte that led to it, recorded only while a word anchor is pending in the
set (so \b-free tables are byte-identical to before). The anchor stays
a pending member, like an EOL anchor; creating a state resolves it twice,
for a word and for a non-word next byte, and the two resolved sets feed
the word and the non-word byte classes of its transitions (the classes
are cut at the [A-Za-z0-9_] edges when an anchor exists). A resolved
continuation that reaches MATCH becomes a conditional match flag
(EDFA_MATCH_IF_WORD / _NONWORD) the walker checks against the next
byte before consuming it, exactly like EDFA_EOL_AT_NEWLINE; end of
input counts as non-word. The mid-line start context splits the same
way (start_other / start_other_word), and the reverse DFA mirrors it
with the roles swapped: the byte just consumed is the look-ahead, the
byte about to be consumed the look-behind, and the entry's liveness
becomes RDFA_WB_LEFT_WORD / _NONWORD. The split restart states of the
leftmost-first table no longer self-loop on prose (one takes word runs,
the other non-word runs), so they are accelerated as a region: a set
of flag-free states whose rows agree on every byte outside a sparse exit
set — the walker scans to the next exit byte and lands in whichever
member the last skipped byte selects. Engine selection is unchanged
otherwise; a word anchor whose continuation reaches a BOL kind (\b^)
stays off the lanes, as does the runtime lazy DFA, which does not model
the anchor. Sets keep word-boundary patterns off their DFA lanes.
Lazy leftmost-first DFA (lazy_lf.mojo, Regex._use_lazy_lf). When
the classic table overflows, match() keeps the lazy DFA of dfa.mojo,
but the search verbs of an anchor- and lookaround-free pattern build the
leftmost-first and reverse automata at runtime instead: the same ordered
state lists, truncation and restart bit as the comptime table, interned
in an open-addressing table, with transitions per byte class. A
transition is stored as its target's row byte offset plus MATCH / START
tag bits, so the walker's fast loop is one register-offset load and one
unsigned compare per byte, four bytes at a time. On START (the bare
restart state) the walk jumps to the next candidate unless those jumps
average under LLF_JUMP_MIN_AVG bytes, when the tag is stripped. A
single-class repeat ((?u)\p{L}{8,13}, single_class_repeat) skips the
reverse walk entirely: every thread steps the same class in unison, so a
byte that kills the oldest live thread kills them all and the oldest one's
match truncates the rest — the match starts where the walk last left the
bare restart state. A cache that stops paying raises, and the call re-runs
on the Pike VM.
The specialized backtracker is comptime-specialized per NFA state: each
_sbt_try_match[pattern, state_idx] instantiation (the NFA is re-derived
inside via the memoized _build_static_nfa(pattern), so symbol names carry
the pattern string rather than a printed NFA) handles exactly one state kind
with all fields baked in. The body is a comptime if chain over the kind, so
every branch belonging to the other kinds is eliminated — what survives is
straight-line code for that state, with no runtime dispatch on kind and no
field loads for branches that no longer exist. The leaf primitives
(_sbt_bitmap_check, _sbt_check_anchor, case folding) are @always_inline,
and the acyclic parts of the call graph are small and terminating, so chains
inline aggressively.
What it is not is one flat function — _sbt_try_match carries no
@always_inline, and could not honour one: a cyclic SPLIT reaches its own
instantiation, which is unflattenable in principle. So the general-SPLIT
branch is genuine recursion, and the engine carries two independent caps:
SBT_BUDGET bounds total work, while SBT_STACK_BUDGET bounds the stack —
budget alone does not, since (?:ab)+ recurses once per byte consumed and has
overflowed the stack on a 50KB input. Exhausting either falls through to the
Pike VM — except for a pattern with a backreference, which the Pike VM cannot
execute (a thread's own captures decide the match, so threads cannot be merged
by state): such a pattern runs unbudgeted, like Python and PCRE, and when its
walk exhausts the stack bound it continues on heapbt_match (executor.mojo),
the same leftmost-first walk over the materialized NFA with its frames on a
heap List, so it is bounded by memory rather than by the thread's stack. An
empty loop iteration ((a*)*) fails that path there, the ECMAScript rule the
Pike VM's per-position dedup also yields. Both are gated at compile time on
the pattern actually carrying a BACKREF, so no other pattern's binary
elaborates them. The stack bound is measured in bytes, not calls: the walk reads
the stack pointer at the checked frames and concedes when it drops below a
floor taken at _sbt_run entry. A call count cannot do the job — the same
frame measures ~120 B in a release build and 600-1500 B under
-D ASSERT=all, a 12x spread, plus a further 2.4x between pattern shapes, so
the 10,000-call cap this replaced was 1.2 MB in one build and 14.6 MB in the
other (which is how (?:a|a{2,})+b on 2000 as came to SIGSEGV under the
test flags).
The floor is the higher of two rules, and both are needed:
- relative —
sp - SBT_STACK_BUDGET(4 MiB, half the 8 MiB main-thread default) bounds what this walk may add; - absolute —
stack_low + SBT_STACK_RESERVE(512 KiB) bounds where the thread's stack actually ends.stack_lowcomes frompthread_get_stackaddr_np/pthread_get_stacksize_npon macOS andpthread_getattr_np/pthread_attr_getstackon Linux. Without it the relative rule says nothing about what the caller already spent: a caller burning 64 KiB per level around a deep walk returned at 60 levels (~3.9 MiB) and SIGSEGV'd at 64, and a macOS secondary thread (512 KiB by default) never had 4 MiB to give.
Regex.__init__ asks for the thread's range once and caches it; the floor
re-asks only when the current stack pointer falls outside the cached range
(i.e. the Regex moved threads). Asking per _sbt_run instead measured
1.388x on static_nested_quantifier.
Residual assumptions. The reserve has to cover whatever nests between two checks, and the check sits in the general-SPLIT branch: one cycle of the call graph, measured at ~4.5 KiB, against a 512 KiB reserve. A pattern that nests hundreds of lookarounds between two cyclic splits would push past that — lookaround frames are real and are not check sites. Platforms other than macOS and Linux cannot be asked for their stack bounds and keep the relative rule alone.
Two cyclic shapes avoid the recursion altogether: a greedy or lazy SPLIT
whose body is a single ANY/CHAR/CHARSET looping straight back becomes a
while loop (is_simple_loop / is_simple_lazy), so a+, [a-z]* and
.*? iterate. sbt_depth_plan computes whether anything is left that can
still recurse without bound — a cycle in the specialized call graph, which
models the simple-loop rewrites, so it is exact rather than an
over-approximation of the NFA — and drops the whole guard (floor included)
when there is none; measured 1.25-1.6x on recursion-heavy patterns when the
check was applied unconditionally. The same pass verifies that the
general-SPLIT states form a feedback vertex set of that graph, which is what
licenses keeping the check in that one branch instead of on entry to every
state.
A counted repetition over a single byte class gets the same treatment,
one level up. nfa.mojo expands x{n,m} into n required copies plus
either a star loop or m-n optional copies NESTED in ? SPLITs
(x{2,4} → xx(x(x)?)?, every skip leaving through one shared exit, as
Rust's compiler does) — right for the DFA lanes (one byte class
determinizes to m+1 states, linear),
ruinous for the backtracker, which pays a function instantiation and a
stack frame per copy: a{1,2000} took ~12 minutes to compile.
_sbt_counted_shape reads that chain back at compile time and the walker
runs it as one bounded loop — consume up to hi, hand back down to lo
with the same auto-possessification the simple loop uses, or extend up
from lo when lazy. It is exact rather than merely equivalent because a
single-class body with no capture inside makes every k-byte path through
the chain the same path, which collapses the recursion's binary-order
enumeration of the optional copies into a plain count order. Two whole
populations are excluded: chains with hi == lo (already a run of tail
calls, so there is no frame to save) and any NFA with a general cyclic
SPLIT (its exit call is not in tail position, so collapsing free frames
into real ones there deepens a walk that already recurses per byte —
(?:a|a{2,3})+b on 2000 as went from completing to overflowing).
Search gets its own prefilters: literal prefixes drive a two-rarest-probe
memmem (it switches to testing both probe bytes per chunk when the rarer
one turns out common in the input, memchr's packed pair), an alternation
of literals at the start drives a Teddy scan, required-byte and
first-byte bitmaps drive shufti/truffle skips, and the [class]+ P …
shape gets a pivot-anchored search that hops between occurrences of a
rare byte. On the backtracker lane three more apply. A required literal
at a bounded offset narrows the candidates to a window before each
occurrence (_use_bt_window). A pattern led by \b and a word byte only
tries word starts (_use_word_start). And after a failed attempt, a
pattern led by a greedy single-class loop with no backreference skips
the rest of that class run (_lead_loop_class): a later start in the
run reaches the same run end and only a subset of the continuation
positions the failed attempt tried. Teddy itself fingerprints the rarest
byte offsets of its literals, not the first ones (in Cyrillic every other
byte is a D0/D1 lead), and when every literal has one of ≤ 3 rare bytes
at one offset it scans for those instead (Sherlock|Street: a memchr on
S).
Two DFA-admission rules are worth knowing because they cut against the
"no alternation, no suffix → backtracker" default. A word-boundary
pattern whose every match is at least WB_DFA_MIN_LEN (8) bytes rides
the leftmost-first lane (\b\w{12,}\b: the backtracker re-checked up to
12 bytes per word it started on), while \b\w+\b, a match per word,
stays put. And an unanchored loop whose exit reaches $ / (?m)$
counts as having a suffix: (?m)\w+$ fails for every word that does not
end a line, which the table decides in one pass.
Report (id, end) for every position where some match of pattern id
ends, regardless of where it started. Duplicates at the same (id, end)
collapse; order is nondecreasing end, ties ascending id; unanchored by
default. This is Hyperscan's reporting model, and it is what a single pass
can actually deliver.
It is not re.finditer: ab|a on "ab" reports end 1 and end 2.
Ground truth is therefore an O(n²) sweep (tools/set_oracle.py), not
finditer — a trap that has bitten this project more than once.
set_nfa.mojo parses each pattern independently (so inline flags stay
per-pattern), demotes captures, splices the fragments into one shared state
pool under a SPLIT chain, and tags each MATCH with its report_id.
Backreferences and lookaround are widened here rather than rejected — see "Exact backrefs and lookaround" below.
litset ──▶ ac ──▶ rose ──▶ mdfa ──▶ bitnfa ──▶ pike
| Lane | When | File |
|---|---|---|
| litset | every pattern is a plain literal | set_literal.mojo |
| ac | all literals, but more than LITSET_MAX of them |
set_ac.mojo |
| rose | patterns carry required literal factors | set_rose.mojo |
| mdfa | general, determinizes within MDFA_STATE_CAP |
set_dfa.mojo |
| bitnfa | determinization blew up, or EOL-consuming continuations | set_bitnfa.mojo |
| pike | word boundaries, or anything above failed | set_pike.mojo |
litset — bucketed Teddy. The candidate mask stays UInt8 but a bucket
holds a list of literal ids, which is Hyperscan's trick for k > 8. No
automaton at all, just shuffles.
ac — Aho-Corasick. Teddy unrolls verification per literal, so it stops at 64 patterns; past that a comptime trie over byte classes, with failure links resolved and output links folded into each state's report slice, walks the input one table lookup per byte and reports every literal ending at each position with no failure-link chasing. Build cost is linear in the total literal length — no determinization — and the root state is SIMD-accelerated to the next possible first byte. Caseless literals collapse their case pairs into single byte classes when nothing in the set needs the two bytes distinguished; otherwise the position expands into alternative trie paths, capped.
rose — literal decomposition, Hyperscan's real performance move. Each pattern is walked at comptime for a literal run that every match must contain at a fixed offset from the match start; all factors pool into one Teddy front end, and a per-pattern anchored DFA runs only at candidate positions. Patterns with no usable factor stay resident on the lane below, over their own union NFA, and the two report streams merge. Measured 10.2x over the multi-accept DFA on sparse 64KB input.
Between the two sits a candidate lookaround: the same comptime walk
also records the byte classes the pattern's consuming chain requires up to
ROSE_LOOK_BYTES positions on either side of the factor (conn=\d+ wants
a digit after it; \d{2}:\d{2} WARN \w+ wants four fixed classes before
and a \w after), and a Teddy hit whose neighbours disagree — or whose
required neighbour falls off either end of the input — never reaches the
confirm DFA. Classes wider than ROSE_LOOK_MAX_POP are dropped rather
than checked, and dropping one does not hide the narrower ones behind it.
Worth ~10% where the factor occurs without its context, neutral where it
does not.
mdfa — determinizes .*?(P0|P1|…) with the unanchored start closure
folded into every state, so the automaton never dies and never restarts.
Each state carries a slice into a flat report pool. Acceleration applies
under a hard rule: a state with any report slice is never accelerated, or a
skip could jump over a reporting position.
bitnfa — LimEx-style bit-parallel NFA over Glushkov positions. Chain successors ride one global bit-shift; joins, loops and anchor crossings live in an exception table touched only when their bits fire. Construction is linear, which is why it is also the streaming engine.
pike — tagged all-match Pike VM. Permanent bottom rung, and the differential oracle every other lane is tested against.
The forward lanes deliberately know nothing about where a match began —
that is what lets them fold the restart into every state. set_reverse.mojo
recovers it afterwards by walking a determinized reverse automaton
leftward from each reported end:
forward: state set = "which NFA states are about to consume input[p]"
reverse: state set = "which NFA states could be ENTERED at position p"
Anchors mirror the forward design with the roles swapped: walking leftward
the byte just consumed is input[p], so EOL resolves during the closure
while BOL depends on the byte about to be consumed and defers to per-state
slices. Word-boundary sets, which cannot ride a DFA at all, use a
SOM-carrying Pike instead.
scan_spans filters that stream to per-id leftmost non-overlapping spans.
It is leftmost-longest, not CPython's leftmost-first; they agree for
greedy unambiguous patterns, which is most of them.
set_stream.mojo. Runs on the bit-parallel NFA regardless of which lane
block mode picks — LimEx construction is linear, so every set that can ride
an automaton can also stream without paying determinization, and the
measured per-byte cost is at parity with the multi-accept DFA.
Stream state is the automaton's own state plus a global offset. Sets whose
reports can depend on the byte after the match ($, (?m)$) additionally
hold one step, so a match ending on the final byte of a write is reported by
the next write or by close() — Hyperscan documents the same caveat. Sets
without such anchors pay no latency at all. scan_vectored falls straight
out.
set_semantics.mojo (SINGLEMATCH, QUIET, min_offset/max_offset/min_length,
expression info) and set_combine.mojo (logical combinations with
! > & > | precedence). All of it is a filter over the report stream rather
than a change to any engine, so the lanes stay exactly as fast for sets that
use none of it, and the semantics are identical on every lane instead of
needing five implementations.
Hyperscan rejects both constructs; its answers are HS_FLAG_PREFILTER (a
superset you confirm yourself) or the separate Chimera library
(Hyperscan + libpcre). emberregex already ships exact engines for both, so
set_prefilter.mojo does the whole job in one library:
- Widen the pattern into a superset the set engines can run — drop lookaround (zero-width and purely restrictive, so dropping only admits more), and replace a backreference with a non-capturing copy of the referenced group's body (the captured text is always in that group's language).
- Confirm each candidate on the exact specialized backtracker, anchored
at the superset's start-of-match and pinned to the candidate end. A
backreference confirm is exact all the way down: it runs unbudgeted and,
when the walk exhausts the stack bound, continues on the heap-stack
backtracker (
heapbt_match) with the same pin — the single-pattern lane's policy, since the Pike VM cannot execute a backreference. A lookaround-only confirm that exhausts its work budget keeps the candidate instead (a superset report, never a missing one).
The pin matters: the engine sees the whole input with an end_at
target rather than a truncated slice, because truncating would hide the
right-hand context a lookahead asserts about.
Three constraints shape the code more than anything else:
- Determinization cost is superlinear in the comptime interpreter.
tools/compile_dashboard.pytracks it. Lanes that avoid determinization (litset, rose, bitnfa) are worth real throughput to reach. - Comptime parameter values are mangled into symbol names. A function
carrying a baked database as a comptime parameter, that the inliner does
not fold, emits a symbol containing the whole database — and the linker
refuses names past a few MB. Every
Listfield costs a fixed ~1 MB regardless of length; the same data asArraycosts ~4 chars per element. HenceRoseViewandReverseView: POD scalars in the parameter, bulk data in separateArrays. - Walkers that take a table as a comptime parameter are
@always_inlinefor the same reason: out of line,_edfa_walk_fromprinted its 1 MB table into its own symbol name and the link failed. An inlined callee must notmaterializea comptime value its caller also materializes, either (a Mojo 1.1.0 bug, reproduced intools/repro_inline_materialize_lifetime.mojo): the two copies share one stack slot whose lifetime ends at the caller's last use, and the callee reads it anyway. The adaptive walker once handed off to its plain instantiation that way and read garbage flags under-D ASSERT=all. - Baked lanes hold zero per-instance state, so
scanneeds nomut selfand no scratch object. Keeping the bit-parallel NFA as the always-builds fallback is partly what lets the API stay non-mutating and thread-safe.
Every engine is differentially tested against the tagged Pike reference
across LCG-generated inputs at chunk-boundary-adjacent lengths, including
bytes ≥ 0x80. Set semantics are ground-truthed against CPython via
tools/set_oracle.py — sweep_ctx, a context-preserving sweep that is
sound for anchors and lookaround where a naive region-bounded sweep is
not. Streaming is checked by exhaustive block/stream equivalence
over every 2- and 3-way chunk split.
Benches live in bench/bench.mojo (single pattern) and
bench/bench_set.mojo (sets), each row pinned by a coverage test so a
benchmark cannot silently time the no-match path.