Ezi_gex — a Unicode-aware regex engine for Zig, with comptime compilation and pluggable backends

Ezigex

I have been working on ezi_code for a while — a Unicode library for Zig covering properties, scripts, casing, normalization, segmentation, and encoding. At some point I realized I had everything needed to build a regex engine with real Unicode support, so I did.

ezi_gex is the result. A few things I found interesting while building it:

The comptime path was the most surprising part. The entire pipeline — scanner, AST, HIR lowering, Unicode range resolution, NFA compilation — runs in Zig’s const-evaluator. The resulting program lands in .rodata. This means \w is already 800+ resolved codepoint ranges sitting in your binary before main runs. The C++ ctre library does something similar with templates; this is the same idea but with comptime and it turned out cleaner than I expected:

// entire NFA compiled at build time — zero runtime compilation cost
const Re = comptime ezi_gex.compileComptime("(?<user>\\w+)@(?<host>\\w+)", .{});

// match runs at runtime against .rodata program
var sc = try @Typeof(Re).Scratch.init(gpa, &Re.program);
defer sc.deinit(gpa);

var slots: [Re.slotCount()]?usize = undefined;
if (Re.captures(&sc, &slots, "bob@example.com")) |c| {
    std.debug.print("{s}\n", .{c.namedSlice("user").?}); // "bob"
}

// or run the match entirely at comptime too
const found = comptime Re.findComptime("hello bob@example.com");

The catch is binary size — a single \w{3,32} is roughly 200KB of range data in .rodata, and 30 Unicode-heavy patterns can add ~625KB to your binary. I documented that tradeoff clearly rather than hiding it. If that matters, compileRuntime has no such ceiling.

The backend contract was the most interesting design decision. Instead of one hardwired matching strategy, matching lives behind a small comptime duck-typed contract. The library ships four backends (pikevm, backtrack, literal, auto) and an Engine(Backend) layer that implements all user-facing operations — find, captures, replaceAll, split etc — once generically on top of two primitives any backend provides. A custom backend in ~40 lines gets the entire operation layer for free:

// your backend only needs to locate a match and fill a slots array
pub const MyBackend = struct {
    pub const caps = Caps{ .captures = true, .stateless = true };
    pub const Program = struct { needle: []const u8 };
    pub const Scratch = struct {};

    pub fn search(p: *const Program, _: *Scratch, input: []const u8, o: SearchOptions) ?Match {
        // your matching logic here
    }
    pub fn isMatch(p: *const Program, s: *Scratch, input: []const u8, o: SearchOptions) bool {
        return search(p, s, input, o) != null;
    }
    pub fn searchCaptures(p: *const Program, s: *Scratch, input: []const u8, slots: []?usize, o: SearchOptions) ?Match {
        const m = search(p, s, input, o) orelse return null;
        if (slots.len >= 2) { slots[0] = m.start; slots[1] = m.end; }
        return m;
    }
    // ... buildAlloc, freeProgram
};

// plug it into the same front door — findAll/count/split/replaceAll all work
comptime ezi_gex.verifyBackend(MyBackend);
var re = try ezi_gex.compileRuntimeWith(MyBackend, gpa, "abc", &diag, .{});

The abstraction costs nothing at runtime because the backend is always chosen at comptime and everything monomorphizes.

Unicode was free because of ezi_code. \p{Script=Greek}, \p{ID_Start}, \p{Alphabetic}, Unicode-aware \w and \b — none of this required new work. ezi_code’s range tables are enumerable at comptime so the HIR builder resolves everything to sorted codepoint ranges at build time. Zero Unicode table lookups at match time:

// these all just work
var re = try ezi_gex.compileRuntime(gpa, "\\p{Script=Greek}+", &diag, .{});
var re = try ezi_gex.compileRuntime(gpa, "\\p{ID_Start}\\p{ID_Continue}*", &diag, .{});
var re = try ezi_gex.compileRuntime(gpa, "(?i)\\p{L}+", &diag, .{}); // case-insensitive Unicode letters

The honest state: it is linear-time (Thompson NFA, no catastrophic backtracking ever — (a*)*b on a 10,000 character input is fine), Unicode-correct, and the architecture is built for adding a lazy DFA later without touching the public API. Throughput today is NFA-simulation level — comparable(approaching, though not there yet) to Go’s regexp package. The prefilter analysis is computed and sitting on every compiled pattern but not yet wired into the engines. That is the next thing.


Current State of the project

Since it is still pre-0.1.0, correctness and stability was priority. So performance is at acceptable level. Any optimisations will be prioritized in later versions.


Also: ezi_code 0.3.0 is coming soon with a few performance improvements I noticed while building ezi_gex — mainly around range table access patterns at comptime. Will post an update on the ezi_code thread when it lands.


Any feedback and suggestions are welcome.

To read architecture design, you can refer this https://github.com/shaik-abdul-thouhid/ezi-gex/blob/main/docs/architecture.md

Supported Zig versions

Zig 0.17.0-dev (master branch). Will not compile on 0.16 stable — the library uses a few APIs that changed recently. I track master because ezi_code does.

Tag: zig-master


AI / LLM usage disclosure

I used Claude as a thinking partner throughout — talking through design decisions, getting explanations of automata theory concepts I was not familiar with (this was my first regex engine), and reviewing architecture.

The library is not vibe-coded. It has ~200 tests, zero allocation per match on the pikevm path (confirmed by benchmarks), and a design document that explains the rationale for every major decision.

1 Like

ezi_gex v0.1.0


In the original post I mentioned the prefilter analysis was computed on every compiled pattern but not yet wired into the engines. That is done. Tagging it.

v0.1.0 is cut. First tagged release with a SemVer commitment — everything in the public surface is annotated @stable-since: v0.1.0 and covered by SemVer from this tag on.

What is new since the last post:

The prefilter, in two pieces:

  1. The literal backend now scans with std.mem.indexOf — SIMD memchr for single-byte needles, Boyer–Moore–Horspool with a skip table for longer ones — instead of an equality check at every position. On memchr-friendly needles that is roughly 20× the old scan. Never slower.

  2. auto now consumes the HIR Analysis on the NFA arm. When every match must start with a fixed literal prefix, its first byte drives a memchr that skips to each candidate start; an anchored NFA run confirms. A ^/\A short-circuit skips the leftward scan entirely. A min_utf8_len gate rejects inputs too short to hold any match. All three bounds are sound — the prefilter never drops a real match, it only avoids running the NFA where one cannot start.

Both work at comptime and runtime. The backend contract is untouched — the DFA slot is still exactly where it was.

Also:

  • ≈210 tests: per-module behavior, cross-backend conformance, runtime + comptime parity
  • Target-agnostic compilation verified: wasm32-freestanding, wasm32-wasi, riscv64-freestanding, aarch64-linux

Still honest about the gap: no lazy-DFA backend, so on general (non-prefixable) NFA patterns throughput is NFA-simulation-bound and below RE2/Rust. That is the next additive tier. The backend contract is the seam for it — adding a DFA does not touch the comptime path or the public API.

zig fetch --save git+https://github.com/shaik-abdul-thouhid/ezi-gex.git#v0.1.0

I don’t want to sound like police, but don’t use Claude when posting to Ziggit. It’s both against the rules, but you are also losing respect. You can value your work even if you use LLMs for assistance, just don’t let it speak on your behalf.

4 Likes

I let it generate the proper format of a message and I personally review it. If I do it manually, I might miss a lot of point, which I tried drafting before posting here :sweat_smile:. And the content was all over the place. Anyways thanks for heads up.

ezi_gex v0.2.0

A few things that landed, they are quite significance for later versions but the public APIs haven’t changed much, so updates are not that obvious.

The main architectural addition is byte-NFA lowering. First step toward lazy-dfa.

Until now the entire pipeline was code-point grained all the way to matching — \w stays as sorted code-point ranges and the VM decodes one scalar per step and tests it against those ranges. This release adds a second lowering: a Thompson NFA whose only consuming instruction is a byte_range test. A Unicode class like \w or \p{Script=Greek} becomes a byte sub-automaton that matches exactly the right code points with zero decode. The Cox/RE2 utf8-ranges algorithm does the conversion. A ByteMap then compresses the program’s byte ranges into equivalence classes — \w+'s automaton collapses to ~112 classes from 256 possible bytes.

A bytepike backend executes this and is proven correct against the code-point engines in conformance.zig. It is not auto’s default — per-byte NFA simulation is not faster than per-code-point. The point is that a lazy DFA can determinize over this substrate using one ByteMap lookup per byte. That is what comes next.

\X grapheme clusters now execute. Was error.Unsupported in v0.1.0. It routes to the backtracker — the Pike VM steps one code point per step and cannot consume a variable-width cluster.

Full case folding landed. (?i)ß now matches ss/SS. Was a documented v0.1.0 gap.

Binary size dropped ~525 KB (turned out that entire array tables were getting copied, which I suppose were supposed to be referenced :sweat_smile:). The engine was linking ezi_code’s per-code-point property page tries (~220 KB) without actually needing them — the range tables do everything.

Also: program-level interning of identical class range-blocks means (\w+)@(\w+) stores \w ranges once instead of twice, and counted repeats no longer multiply the table — \w{3,32} costs one \w block, not 35.

Any feedback or suggestions are always welcome.

ezi_gex v0.3.0

Repo: GitHub - shaik-abdul-thouhid/ezi-gex: Unicode aware regex engine for Zig — runtime and comptime. Thompson NFA: linear-time, ReDoS-safe; Literal Prefilter; Backtrack; Lazy Dfa; Eager Dfa; Full \p{} Unicode properties, named captures, case folding, pluggable backends. Zero allocation per match. WASM-compatible. · GitHub

Architecture: ezi-gex/docs/architecture.md at main · shaik-abdul-thouhid/ezi-gex · GitHub
Usage guide: ezi-gex/docs/usage-guide.md at main · shaik-abdul-thouhid/ezi-gex · GitHub

Before anything else, why ezi_gex when there are other great community libraries.

I built ezi_gex because Zig didn’t have a regex engine with real Unicode — \p{Script=Greek}, \p{ID_Start}, \X, case folding that turns ß into ss — and I already had ezi_code sitting there with all the tables. So the purpose is narrow: a regex engine that’s Unicode-correct, linear-time and safe to point at untrusted input (Thompson/RE2 lineage — no backtracking blowup, ever), working at comptime and runtime through the same API, over memory you own.

Like any other zig library/project, my mantra: No hidden allocations, one immutable program you can share, one scratch per thread (a convention that works well for most of the cases… If you want a truly thread-safe regex engine, then this is not for you).

The libraries core value proposition is for letting users write their own algorithm/backends/engines and easily plug and use it. The components are as standalone as possible. You can hand write tuned algorithm for your use case and duck type and use it without having to too much care of handling flags, unicode code points, etc.

But the design bet underneath all of it is the part I care about most: matching isn’t hardwired. It lives behind a small comptime duck-typed contract, and a generic Engine(Backend) layer implements every user-facing operation — find, captures, findAll, count, split, replaceAll, the iterators — exactly once, on top of two primitives any backend provides (locate a match; fill a slots array). The seven backends the library ships (auto, edfa, dfa, pikevm, backtrack, literal, bytepike) are all just implementations of that contract. So is anything you write.

A backend doing literal substring search, with the whole operation surface for free:

const std = @import("std");
const gex = @import("ezi_gex");

pub const MyBackend = struct {
    pub const caps = Caps{ .captures = true, .stateless = true };
    pub const Program = struct { needle: []const u8 };
    pub const Scratch = struct {}; // stateless — nothing carried between searches

    pub fn search(p: *const Program, _: *Scratch, input: []const u8, o: SearchOptions) ?Match {
        const i = std.mem.indexOfPos(u8, input, o.start, p.needle) orelse return null;
        return .{ .start = i, .end = i + p.needle.len };
    }
    pub fn isMatch(p: *const Program, s: *Scratch, input: []const u8, o: SearchOptions) bool {
        return search(p, s, input, o) != null;
    }
    pub fn searchCaptures(p: *const Program, s: *Scratch, input: []const u8, slots: []?usize, o: SearchOptions) ?Match {
        const m = search(p, s, input, o) orelse return null;
        if (slots.len >= 2) { slots[0] = m.start; slots[1] = m.end; }
        return m;
    }
    // + buildAlloc (Hir → Program) and freeProgram; add buildComptime to run it at comptime too
};

comptime gex.verifyBackend(MyBackend);  // compile error if the contract isn't met

var re = try gex.compileRuntimeWith(MyBackend, gpa, "abc", &diag, .{});
// re.find / re.captures / re.findAll / re.split / re.replaceAll all work now — you wrote none of them

verifyBackend checks the shape at compile time, and because the backend is a comptime parameter the whole thing monomorphizes and inlines — no vtable, no runtime cost for the indirection. The full walkthrough (including buildAlloc and the comptime path) is in usage guide §8; the contract itself is in architecture.md.

what’s in v0.3.0

0.2.0 ended on “a lazy DFA engine next…” 0.3.0 is that step — except the DFA that ended up as auto’s default span engine is the eager one, fully determinized at build, not the lazy cache I’d planned. The lazy DFA still exists and it’s the fallback engine now, for patterns the eager one won’t fit in its state budget. So headline is just that: auto no longer matches by NFA simulation only… The default path is a frozen DFA table and a state = trans[state][class] loop. The Thompson/Pike VM is still in there — it fills captures and it’s the correctness backstop everything is conformance-checked against — but it isn’t what scans your input anymore.

The eager DFA. It determinizes the whole byte automaton (the substrate from 0.2.0) at build and freezes the result into an immutable states × byte_classes table. Matching is then a bare table walk: one ByteMap lookup per input byte, no decode, no live-thread bookkeeping. On class scans — \w+, [A-Za-z]+, \p{L}+ — that lands at Rust regex parity, ~1.1–1.3× (Spent few nights to reach here, previously the parity was 8-10x on average with my naive implementation). 0.2.0 was NFA-simulation throughput (“approaching Go’s regexp”); the same patterns are now 4–20× faster than my own code-point NFA depending on the shape, and at parity with Rust on the scans. (please take these ‘x’ factor parity numbers with a grain of salt, the difference may vary based on the machine, pattern and input. All these numbers were measured and computed on my machine for ‘ascii’, ‘multilingual’ and ‘pathological’ inputs. Though for literal matching rust-regex is currently really fast because of its specialized simd path).

Because it’s eager and not a runtime cache, it also builds at comptime. A comptime-compiled pattern freezes its DFA straight into ro_data and matches by table walk — no determinizer, no compiler code in the binary. And if both the pattern and the input are comptime-known, the whole match folds to a constant; nothing engine-related survives into the binary at all.

const gex = @import("ezi_gex");

// pattern baked into ro_data at build; match runs at runtime
const re = comptime gex.compileComptime("\\d{3}-\\d{4}", .{});

// (a) match at runtime over the frozen table
var buf: [@TypeOf(re).Scratch.bufferLen(&re.program)]@TypeOf(re).Scratch.Buf = undefined;
var sc = try @TypeOf(re).Scratch.initBuffer(&buf, &re.program); // no allocator
_ = re.find(&sc, "call 555-1234").?.slice("call 555-1234");     // "555-1234"

// (b) match entirely at comptime — folds to a compile-time constant
const ok = comptime re.isMatchComptime("call 555-1234");         // true, baked in

As you can see the weird @TypeOf sugar used in the snippet, this weird syntax is the result of my design decision. Abstracting behind a method or a function call will leak the implementation, so just left it as it is. Hopefully someone can give suggestion on how to smoothen this edge.

That second form is the one I keep reaching for now — validating embedded constants, version strings, config literals at build time. A bad value becomes a @compileError instead of a runtime surprise, and the engine doesn’t ship.

find is O(n) on every pattern now, and it’s a build-time decision, not a per-search probe. computeProne looks at the determinized program once and picks one of three arms which are only sane strategies I could think of, these cover lots of edge-cases:

  • consuming loop is itself accepting (\w+, \d+, [A-Za-z]+) → plain anchored restart, one greedy table walk per match.
  • a non-accepting cycle reachable from a start (\w+@\w+ on a long word run — the pre-@ run is a cycle that never accepts) → anchored restart is Θ(n²), so it takes a reverse-DFA two-pass instead: a forward pass finds the end, a frozen reverse DFA finds the leftmost start. Two linear passes. (The Cox/RE2 reverse trick.)
  • a trailing $ (\d+$, \w+$, \w+@\w+$) was the one I had to go and kill on its own. The end is pinned to input.len, so there’s nothing to scan forward for — one reverse pass from the end finds the start. No anchored restart, which would otherwise be Θ(n²) on the same begin-but-don’t-complete shapes. A $ that isn’t a genuine trailing anchor (a$|b, optional (a$)?, multiline (?m)$) is detected and declined to the NFA rather than silently mishandled.

Tier-1 literal prefilter is wired. The literal backend now scans with memchr / Boyer–Moore–Horspool skip tables instead of an eql per position (~20× on memchr-friendly needles, never slower). And auto consumes the HIR Analysis (usage guide §7) on NFA patterns: a leading-literal memchr jumps to candidate starts, the rarest required byte drives a fast-reject (no @ in the input → no \w+@\w+ match, bail at once), and a min-length gate drops inputs too short to hold a match. Every fact is a one-sided bound, so the prefilter never drops a real match. Toggle with strategy.prefilter.

What it looks like

Runtime compile, caller-owned scratch, no surprises:

var diag: gex.Diagnostic = .{};
var re = try gex.compileRuntime(gpa, "\\w+", &diag, .{});
defer re.deinit();

var sc = try @TypeOf(re).Scratch.init(gpa, &re.program); // one per thread
defer sc.deinit(gpa);

_ = re.isMatch(&sc, "abc123");          // bool
_ = re.find(&sc, "x abc123 y");         // ?Match → "abc123"

Named captures resolve into a caller-owned slots array:

var re = try gex.compileRuntime(gpa, "(?<user>\\w+)@(?<host>\\w+)", &diag, .{});
const slots = try gpa.alloc(?usize, re.slotCount());
if (re.captures(&sc, slots, "ping bob@example")) |c| {
    _ = c.namedSlice("user").?;  // "bob"
    _ = c.namedSlice("host").?;  // "example"
}

Unicode is the reason the project exists — all of this goes through ezi_code and resolves to sorted codepoint ranges at build, zero table lookups at match time:

_ = try gex.compileRuntime(gpa, "\\p{Script=Greek}+", &diag, .{});
_ = try gex.compileRuntime(gpa, "\\p{ID_Start}\\p{ID_Continue}*", &diag, .{});
_ = try gex.compileRuntime(gpa, "(?i)\\p{L}+", &diag, .{}); // case-insensitive Unicode letters; (?i)ß matches ss

You can pin a backend instead of letting auto choose — same API on the returned type:

var re = try gex.compileRuntimeWith(gex.backends.edfa, gpa, "\\w+", &diag, .{}); // force the eager DFA
// or .pikevm / .backtrack / .literal / .dfa / .auto

findAll/count/split/replaceAll (with $1/${name} templates) all sit on the same operation layer — see usage guide §3. And the matching strategy is still behind a comptime duck-typed contract — a custom backend in ~40 lines inherits the entire operation layer for free (usage guide §8).

Smaller things that landed

  • Literal alternation isn’t quadratic anymore — foo|bar|baz|qux scans with a single indexOfAny pass instead of one scan per branch.
  • The eager DFA builds only the tables it’ll use: utrans and the reverse table are constructed only for prone / trailing-$ patterns, so a plain \w+ stores just its forward trans (~141 KB) instead of all three (~1 MB).
  • ~300 tests. Conformance pins every backend’s spans and captures to the Pike VM and fuzzes the strategy knobs, so the DFA can’t silently disagree with the reference.

Honest perf state

Class scans are at Rust parity. Where I still lose, and lose badly, is anything that wants a SIMD literal prefilter — literal alternations, interior literals, case-insensitive literal prefixes are 10–30× behind Rust (and in one test it was ~990x ahead :face_without_mouth:…) because there’s no Teddy / memmem yet. (?m)^ line anchors stay on the NFA and are several times behind. Those are the next release; the analysis facts to drive them are already computed on every pattern, just not wired to a scanner.

Caveats

  • Binary size is the eager DFA’s cost. A Unicode-class DFA is a large dense table (\w is a few hundred states), so size grows with Unicode-heavy patterns. Hopcroft minimization + a sparse encoding aren’t done (planned for upcoming releases). Comptime patterns also bake their class ranges (~6.3 KB per distinct \w; identical classes are interned). If size matters, pin a cheaper backend or use compileRuntime (no ceiling). The trade-off is documented in architecture.md §3.
  • Captures never use the DFA. They always run on the Pike VM, anchored at the DFA’s span. Capture-heavy workloads get the DFA’s fast span location but Pike-VM-speed extraction.
  • \X grapheme clusters route to the backtracker (the Pike VM steps one codepoint and can’t consume a variable-width cluster). The backtracker’s visited set grows during a match, so the zero-alloc-per-match property has an asterisk on that one backend; the Pike VM and a buffer-backed scratch allocate nothing while matching.
  • Line anchors and \b aren’t in the DFA yet. (?m)^, (?m)$, \b keep those patterns on the NFA.
  • The lazy DFA fallback is runtime-only, and its transition cache grows on demand (bounded). It’s the path for patterns whose eager DFA overflows the state budget. Makes no sense to add to comptime, instead it would perform worse instead because of lots of memory restrictions.
  • Comptime matching is bounded by the eval-branch quota. Big/pathological patterns can blow it or grow ro_data — prefer compileRuntime for those.
  • No backreferences, no lookaround, ever. full stop. Thought of implementing this and turned out to be massive pain not worth taking.
  • One scratch per thread. The compiled regex is immutable and shareable; the Scratch is the mutable per-search state and is not safe to share across threads (usage guide §9).
  • Pre-1.0. Public API may still change; everything stable is annotated @stable-since.

Upcoming verssions

Really need to work on prefilters. This is something that was on the todo list for past one version. Also need to heavily optimise on the bloat that eager-dfa adds to unicode range patterns. And Api stabilisation.

1 Like

ezi_gex v0.5.1 — teddy simd introduction, optimisations, fuzz-driven correctness (catching up since 0.3.0)

Repo: GitHub - shaik-abdul-thouhid/ezi-gex: Fast, Unicode-aware regex engine for Zig — runtime & comptime, linear-time (ReDoS-safe), full \p{} properties & scripts, SIMD-accelerated. Zero-alloc matching, WASM-ready. · GitHub

Architecture: ezi-gex/docs/architecture.md at main · shaik-abdul-thouhid/ezi-gex · GitHub

Usage guide: ezi-gex/docs/usage-guide.md at main · shaik-abdul-thouhid/ezi-gex · GitHub

3-way benchmark (vs Rust regex + Go regexp): regex-bench/results/REPORT.md at main · shaik-abdul-thouhid/regex-bench · GitHub

Limitations: ezi-gex/docs/limitations.md at main · shaik-abdul-thouhid/ezi-gex · GitHub

Oh boy, it was a long ride until this point. Last post was for 0.3.0 and I went quiet for a few releases. Below is the entire summary until now.

Quick reminder of what this is, since it’s been a while (tl;dr): a regex engine for Zig with real Unicode (\p{Script=Greek}, \X, ß->ss folding) coming from ezi_code, Thompson/RE2 lineage so it’s linear-time and safe on untrusted input, the same API at comptime and runtime, over memory you own. And the bet I still care about most — matching isn’t hardwired: it’s a small comptime duck-typed contract, write a ~40-line backend and you inherit find / captures / findAll / split / replaceAll for free (check usage guide).

Four releases happened — v(0.3.1, 0.4.0, 0.5.0, 0.5.1). The shape of it:

v0.3.1 First patch version

A real O(n^2) ReDoS in the default auto engine — the prefilter confirmed anchored at every prefix-byte occurrence, so a+b on aaaa…a! was ~1.1 s at 64 KiB (186 µs after). Plus engine/redos.zig, a ReDoS-immunity regression suite is added with extensive tests plugged.

v0.4.0 (Simd / Teddy Simd / Two byte memmem’s)

Two main things,:

  • SIMD prefilters landed (the thing the 0.3.0 post promised as “next”): Teddy multi-literal matcher (engine/simd.zig + engine/teddy.zig, slim-128 + fat-256/AVX2), a portable two-byte memmem for single literals, a whole-run literal start-skip, case-insensitive/small-class => Teddy multi-prefix, and a leading-class first-byte SIMD scan (engine/classscan.zig).

  • The byte DFAs gained the features that kept patterns on the NFA: \b/\B on both DFAs (ASCII eager + Unicode lazy decode-hybrid), (?m) line anchors (eager and lazy), text_end $ on the lazy DFA, a one-pass NFA capture fast path (backends.onepass), and Hopcroft minimization of the eager DFA (this really cutt down the state count a lot).

Result: geomean Rust lead 3.54× → 2.55×, ezi fastest in 9/32 cells (was 4). Again, these number vary with runs and machine warmth. For current bench results check out this link

v0.5.0 API round-out + the prefilter wins that closed the Rust gap + compile-time cliff removal + fuzz-driven correctness

Fuzz landed in this tag.

  • API parity: replace / replaceN / replaceAllAlloc / replaceAllWith + capturesAt / splitN / groupIndex / groupName.

  • Four results-invariant auto prefilter wins: \bthe\b ~8×, \d{4}-\d{2}-\d{2} ~16×, (?m)^ log-line ~4.7×, email compile ~140× → geomean Rust lead 2.55× → ~1.12× (now beats Rust on several cells).

  • The \p{…}/\w compile-time cliff is gone: determinization + byte-lowering linearized (\p{L}+ 31.9 ms → ~1 ms, the\s+\p{L}+ 107 ms → 0.57 ms; 17–188×). Search throughput unchanged — only the build got cheaper.

  • Configurable {m,n} ceiling (scan-time DoS guard), the coverage-guided fuzz harness (8 targets), and the 6 leftmost-first DFA correctness fixes it drove. Per-module test units.

v0.5.1 patch: fuzz-found leftmost-first fixes + limitations doc

5 leftmost-first correctness fixes (found during fuzzing) for degenerate anchor / empty-width-loop / \b patterns surfaced by the fuzz suite, plus a new docs/limitations.md (deliberate semantic choices + two deferred edge cases).

An honest perf state

*all benchmarks were run on mac M4, could differ on different machines and OS.

the entire bench result can be found in this link

Near or beating Rust on literals (Sherlock 40.7 vs 38.4 GiB/s), \bthe\b (3.17 vs 3.41), and digits (\d+/\p{N}+ ~19 vs ~3 GiB/s — way ahead). Where I still lose: dense letter classes — \p{Lu}\p{Ll}+ (507 vs 695 MiB/s), \p{L}+ (185 vs 209), and the\s+\p{L}+ (~3× behind).

Those want a faster class-scanning loop / more SIMD I don’t have yet. Take it as a grain of salt on the x-factors — one machine, these patterns, these inputs.

And still the target agnostic still holds true, if specific aarch (x86, aarch64) isn’t found, it will fall back to scalar matching.

Caveats

What changed since 0.3.0: \b and (?m) line anchors are off the NFA and on the DFA now, and the literal prefilters landed.
Still true: captures always run on the Pike VM (anchored at the DFA span); \X routes to the backtracker (asterisk on zero-alloc-per-match there); no backreferences/lookaround, ever; one scratch per thread; comptime is bounded by the eval-branch quota; and Unicode-class eager-DFA binary size is still the weak spot (minimization helped the state count, sparse encoding is still pending).

Also the benchmarks and SIMD conformance was tested on aarch64, but the correctness is tested on rosetta(mac) for x86.

**Note: Two correctness edge cases on pathological patterns are documented and deferred in limitations.md

what’s next

First and foremost, eagerly waiting for zig to hit 0.17 and I pin the min-zig version. :smiling_face_with_tear:. Can’t have it hanging on a dev branch.

Now, the main long term goal is fuzzing and fixing the pathologic cases or correctly document them to not get caught unaware. Though the benchmarks are just a vanity numbers, but I will look and optimise places or try any low hanging fruits (because why not). There is still a lot of optimisations that can be done. Or try to experiment for adding JIT like PCRE2 (though I don’t have much idea on it, but it is a great experiment wink wink) since my library allows me very high degree of freedom in this regards, I can try out different implementations, and fallback to auto if something doesn’t work out.

Yes, and below is rebar styled benchmark against rust-regex go-regexp. Though rust outperforms in many patterns, the difference is very near.

image

image

**And as always, open to all feedbacks.