An ECMA-262 regular expression engine in Zig, independent of the JavaScript engine that uses it.
- A regex engine that follows ECMA-262's syntax and semantics, Annex B included, with the gaps listed under Limitations.
- It does not implement the
RegExpobject. It gives a host the primitives to build one:Regex.execAt(one search from an index, into caller-ownedMatchSlots),Regex.advanceIndex(thelastIndexstep for empty matches), a reusableScratch, and subjects in WTF-8 or UTF-16 (Subject).lastIndex, flags objects,execarrays andSymbol.replacestay in the host. - Engine-agnostic: no dependency on a JS engine's values, objects or garbage collector.
- A convenience facade for plain Zig use:
find,findAll,test_,replace,replaceAll, andRegex.iterator(every match without allocating). - A C ABI (
src/c_api.zig,zig build shared): 40zregex_*symbols, stable for FFI consumers such as the test262 harness (docs/API.md). It is not a documented public C API: there is no C header, and callers declare the functions they use fromsrc/c_api.zig.
- T0 (regular patterns): complete. Linear in the input: fast paths (literal, class run, Shift-And), a forward and a reverse DFA built at compile time within a cap (since 0.8.0), and a Pike VM without captures and a tagged VM with captures for the rest and for groups.
- T1 (Unicode:
u/v,\p{…}, full case folding): in development (F5). Since F5a,uand\p{…}run on T0 in code-point mode (the DFA too since 0.8.0); since F5b, so does case folding underi(with and withoutu); since F5c 2c-b,v. - T2 (backreferences, lookaround): on the explicit-stack backtracker, with a step budget.
Lookbehind: fixed length without captures (F6b step 1), and outside
u/vany other without a lookaround inside, captures and backreferences included (F6b(1)-(3)); the rest iserror.UnsupportedFeature. - test262: 3383 of the 3406 entries that run (99.3%); 432 are skipped, most of them RegExp modifiers and host-only cases: see Compatibility.
- Divergences from V8 in the differential: 0 different results; 2 patterns hit the step limit (T2).
| Feature | Tier | Status |
|---|---|---|
| Literals, quantifiers, classes, anchors | T0 | OK |
| Capturing groups (numbered and named) | T0 | OK |
. and the s flag |
T0 | OK |
^ $ \b \B |
T0 | OK |
| Alternation, prefilters, fast paths | T0 | OK |
u, \p{…} |
T1 | OK (T0's linear VM, F5a) |
Unicode case folding under i (with and without u) |
T1 | OK (T0's linear VM, F5b) |
v, \q{…}, properties of strings |
T1 | OK (T0's VMs and DFA, F5c); v with i: see LIMITATIONS |
Case folding under v (iv) |
T1 | Literals and classes as iu (F7c-0); properties, negated foldable classes and set operations on open operands: error.UnsupportedFeature |
| Backreferences | T2 | OK (backtracker) |
| Lookahead | T2 | OK (backtracker) |
Lookbehind: fixed length without captures (any mode), or variable length, captures and backreferences inside (no u/v) |
T2 | OK (backtracker; backward atoms since v0.6.0, F6b) |
Lookbehind with a lookaround inside a backward body, or matched backward under u/v |
T2 | error.UnsupportedFeature (lookaround inside: 1.x) |
"OK" means it works and passes the tests. It does not mean optimized. Which executor runs a
pattern is decided at compile time from the pattern (zregex.internal.analyze); the results are the
same whichever runs it.
With Zig 0.17. Add the dependency (this writes the hash into build.zig.zon):
zig fetch --save https://github.com/carlos-sweb/z-regex/archive/refs/tags/v0.9.1.tar.gzIn build.zig:
const zregex = b.dependency("zregex", .{ .target = target, .optimize = optimize });
exe.root_module.addImport("zregex", zregex.module("zregex"));The facade: find a match and its groups.
const std = @import("std");
const zregex = @import("zregex");
pub fn main() !void {
var gpa = std.heap.DebugAllocator(.{}).init;
defer _ = gpa.deinit();
const allocator = gpa.allocator();
const re = try zregex.Regex.compile(allocator, "(\\d{3})-(\\d{4})");
defer re.deinit();
const text = "Call 555-1234 or 555-9876";
if (try re.find(text)) |match| {
defer match.deinit();
std.debug.print("match: {s}\n", .{match.group(text)}); // 555-1234
std.debug.print("group 1: {s}\n", .{match.getCapture(1, text).?}); // 555
}
}What a host (a JS engine's RegExp) uses: execAt into caller-owned slots, a reused
Scratch, advanceIndex for empty matches, and WTF-8 or UTF-16 subjects.
const std = @import("std");
const zregex = @import("zregex");
pub fn main() !void {
var gpa = std.heap.DebugAllocator(.{}).init;
defer _ = gpa.deinit();
const allocator = gpa.allocator();
const re = try zregex.Regex.compile(allocator, "(\\d{3})-(\\d{4})");
defer re.deinit();
// One Scratch per thread, reused across calls: 0 allocations once warm.
var scratch = zregex.Scratch.init(allocator);
defer scratch.deinit();
// slots[2g], slots[2g + 1]: start and end of group g (group 0 = match).
var buf: [6]?usize = undefined;
var out: zregex.MatchSlots = .{ .slots = buf[0..re.slotCount()] };
// Every match, as a host's `lastIndex` loop would do it.
const subject: zregex.Subject = .{ .wtf8 = "Call 555-1234 or 555-9876" };
var index: usize = 0;
while (try re.execAt(subject, index, &scratch, &out, .{})) {
const start = buf[0].?;
const end = buf[1].?;
std.debug.print("[{d}, {d}) group 2 at [{d}, {d})\n", .{ start, end, buf[4].?, buf[5].? });
index = if (end == start) re.advanceIndex(subject, end) else end;
}
// The same regex on a UTF-16 subject (a JS string's code units):
// indices are UTF-16 code units.
const units = std.unicode.utf8ToUtf16LeStringLiteral("tel 555-1234");
if (try re.execAt(.{ .utf16 = units }, 0, &scratch, &out, .{})) {
std.debug.print("utf16: [{d}, {d})\n", .{ buf[0].?, buf[1].? });
}
}Every match without allocating, as findAll finds them: Regex.iterator runs the same
execAt loop over your Scratch and MatchSlots.
const std = @import("std");
const zregex = @import("zregex");
pub fn main() !void {
var gpa = std.heap.DebugAllocator(.{}).init;
defer _ = gpa.deinit();
const allocator = gpa.allocator();
const re = try zregex.Regex.compile(allocator, "(\\d{3})-(\\d{4})");
defer re.deinit();
var scratch = zregex.Scratch.init(allocator);
defer scratch.deinit();
var buf: [6]?usize = undefined;
var out: zregex.MatchSlots = .{ .slots = buf[0..re.slotCount()] };
// Every match, as findAll finds them, without allocating: `m.slots`
// is `out`, overwritten by the next call.
const text = "Call 555-1234 or 555-9876";
var it = re.iterator(.{ .wtf8 = text }, &scratch, &out, .{});
while (try it.next()) |m| {
std.debug.print("{s} (group 1: {s})\n", .{ text[m.start..m.end], text[m.slots[2].?..m.slots[3].?] });
}
}Flags are compile options; resource limits (for patterns on the backtracker) are per execution:
const re = try zregex.Regex.compileWithOptions(allocator, "^hello$", .{
.case_insensitive = true,
.multiline = true,
});
defer re.deinit();
const limits: zregex.ExecLimits = .{ .max_steps = 100_000, .max_backtrack_stack_bytes = 1 << 20 };
const found = try re.execAt(.{ .wtf8 = "say\nHELLO" }, 0, &scratch, &out, limits);These examples are compiled and run against the library (zig build, Zig 0.17).
The root of the zregex module and the C API are the stable API; the contract is
docs/API.md: the 19 stable declarations, the errors and their C codes, what
is a breaking change and how a symbol is deprecated.
- Not covered:
zregex.internal(the engine's pieces, for this repository's tests, tools and bench) and the diagnostic fields ofCompileOptions(force_tier,tier_diagnostic,t0_prefilters,t2_look_linear). They can change in any release. - The error rule (the freeze): valid syntax that zregex doesn't implement is
error.UnsupportedFeature(C:ZREGEXP_ERROR_UNSUPPORTED, 9), never a wrong result. A later release only removes such cases; it never adds an error toRegexErrororExecError.
T0 runs in O(n·m), without ReDoS. See docs/BENCHMARKS.md for numbers against V8, Rust regex, PCRE2, zig-regex and zoptia0regex.
-
test262: 3383 of the 3406 entries that run (99.3%), the same status with UTF-16 and WTF-8 subjects. Baseline:
scripts/test262/baseline.json. The 23 that run and don't pass: 4 lookbehind entries that areUnsupportedFeature(2 with a lookaround inside a backward lookbehind,nested-lookaround, a 1.x decision; 2 underu,named-groups/lookbehind), 4 host (JS lexer) and 15 not extractable. -
432 test262 entries are skipped and are not in 3383/3406:
Skipped Entries Why vflag1 The 311 entries that pass run ( scripts/test262/v-subset.json); the other is the host'sRegExp modifiers (ES2025) 377 Not implemented, pending until further notice; the harness excludes this unsupported engine feature Legacy RegExp (Annex B statics) 52 Host Fail in V8 itself / host flag validation 2 Host Node 26 enables 78 additional passing entries compared with the Node 22 baseline (including duplicate named groups and host
RegExp.escapetests). Both v0.9.0 built with Zig 0.16 and v0.9.1 built with Zig 0.17 produce the same statuses; this is a harness coverage change, not a matching capability change. -
differential-v8againsttests/differential/reference/diff-F7a.json: 0 new, 0 gone, 0 changed. It has no different result; 2 T2 patterns hit the step limit. -
Internal differential (every capture slot, V8 as the arbiter where the executors disagree): 0 crashes and 0 two-pass mismatches in 12.76 M runs over the real corpora and 14.16 M over the iteration corpus (
tests/corpus/). -
0 divergences from V8 on T0.
- Three tiers: T0 (linear VMs), T1 (Unicode, F5), T2 (backtracker). A pattern's tier comes
from
zregex.internal.analyze. - One parser, one HIR, several executors: every executor runs the same HIR, so a pattern means the same thing wherever it runs.
- Layers as build modules: the table in
build.zigsays which module may import which (tier0never sees Unicode data or the backtracker);zig build check-layersenforces it. - How it works: docs/ARCHITECTURE.md. Design and phases: docs/REGEX_TIERS_PLAN.md.
zig build # shared library with the C ABI (zig-out/lib)
zig build test # unit and integration tests (also -Doptimize=ReleaseSafe)
zig build check-layers # module layering: lint, forced analysis, canaries
zig build bench # performance baseline (ReleaseFast)The Zig 0.16 → 0.17 migration has three documented performance gaps; no experimental performance workaround is retained. See Zig 0.17 upgrade gaps for the investigations and the remaining performance gaps. The v0.9.1 benchmark includes the Zig 0.16 baseline.
test262 needs Node and the harness's dependencies:
bash scripts/test262/fetch.sh
npm ci --prefix scripts/test262
zig build test262 # UTF-16 subjects; test262-wtf8 for WTF-8The cross-engine benchmark: bench/compare/prepare.sh, then node bench/compare/run.mjs 10
(see bench/README.md).
- T1 is incomplete (F5):
vwithiisUnsupportedFeaturewherev's MaybeSimpleCaseFolding differs fromiu(LIMITATIONS).u,\p{…}, case folding andvrun on T0's VMs. - T2 uses the current backtracker, bounded by a step budget: a pathological pattern stops
with
error.StepLimitExceededinstead of an answer. - Lookbehind (F6b, v0.6.0): of fixed length without captures or
backreferences inside (29 of the 34 lookbehind patterns in the npm corpus), or outside
u/vany other without a lookaround inside (captures saved and backreferences compared right to left); any other iserror.UnsupportedFeature(C APIZREGEXP_ERROR_UNSUPPORTED). A lookaround inside a backward lookbehind is a 1.x decision. The old 100-character window (D7) is gone. - Valid syntax that isn't implemented is
error.UnsupportedFeature(C APIZREGEXP_ERROR_UNSUPPORTED), never a wrong result: a lookbehind matched backward (variable length, captures or backreferences inside) underu/vor with a lookaround inside, and, in a build with-Dproperties_of_strings=false, the properties of strings other thanEmoji_Keycap_Sequence. The table: docs/LIMITATIONS.md. - RegExp modifiers (ES2025),
(?i:…),(?-m:…): not implemented, pending until further notice;(?i:a)iserror.UnsupportedFeature. - Patterns with a raw, non-UTF-8 byte (WTF-8 only) stay on the backtracker, as does a tagged program over the slot bound.
The full list: docs/LIMITATIONS.md; how each phase got here, with its measurements: docs/HISTORY.md. How the engine works: docs/ARCHITECTURE.md.
- T0: done (F4a, F4b; v0.3.0; v0.3.1 adds the SIMD literal search; v0.3.2 adds
Regex.iterator). - v0.4.0: F5a, F6a and F5b (T1 without
von the VM, explicit-stack backtracker, full case folding underi; test262 2978/3017). - v0.5.0: F7a, F7b, B′ and C API error names (RepeatMatcher steps 4 and 2.b, compile cost, fixed-length lookbehind on the explicit-stack backtracker; test262 2968/3017; release notes).
- v0.5.1 (E0): honest errors: valid syntax that isn't implemented is
UnsupportedFeature;vappliesu's early errors (release notes). - v0.6.0 (E1, F6b): lookbehind matched backward outside
u/v: variable length, captures and backreferences inside; test262 2994/3017 (release notes). - v0.7.0 (F7c): the API freeze. The stable API and its contract
(docs/API.md),
zregex.internal, honest errors forvwithiand for invalidvset operations, C error codes, documentation and benchmarks re-measured (release notes). - v0.7.1: T0 throughput (J+C+B).
x+without a duplicated body, a Shift-And path for fixed ASCII sequences and a skip to the run before a required inner literal; API and results unchanged (release notes). - v0.8.0: T0-A, the DFA on T0. A forward and a reverse DFA built at compile time, within a
cap, for T0's programs: without asserts, with
^/$/\b/\B, and in code-point mode (u/v); API and results unchanged (release notes). - v0.9.0: F5c,
von T0.vcomplete but forvwithi's MaybeSimpleCaseFolding: invalid class set syntax rejected, bare operands, chained operators, nested unions,\q{…}strings and the seven properties of strings;vpatterns run on T0's VMs and DFA; test262 3305/3328; API unchanged (release notes). - v0.9.1: migrate Zig 0.16 to 0.17. Zig 0.17 is the minimum compiler; the public API and C ABI remain unchanged. Three performance gaps are documented, with no experimental workaround retained. Benchmarks were rerun against v0.9.0 and all competitors (release notes).
- To 1.0 (docs/plans/ROADMAP_1.0.md): E0 (v0.5.1) → E1, full
F6b (v0.6.0) → F7c: API freeze and documentation (v0.7.0, done) → T0 throughput
(v0.7.1, done) → a DFA for T0 (docs/plans/T0-A.md) and the full
benchmark (v0.8.0, done) → F5c,
von T0 (v0.9.0, done) → 1–3 months of production use → v1.0.0 with the same API. RegExp modifiers: pending until further notice. - F5, T1 (Unicode): F5a done (
uand\p{…}on the VM, every UCD property name); F5b done (full case folding underi); F5c done in v0.9.0 but forvwithi's MaybeSimpleCaseFolding (UnsupportedFeaturewhere it differs fromiu). - F6a, T2 without lookbehind: done (explicit-stack backtracker, capture trail, LookLinear; F6a(1)–(3)).
- F6b, lookbehind: closed in v0.6.0. B′ (fixed length without captures, forward) and
matching backward outside
u/v(variable length, captures, backreferences). Not covered,UnsupportedFeature: a lookaround inside a backward lookbehind (1.x) and any backward lookbehind underu/v.
No dates. Phases and exit criteria: docs/REGEX_TIERS_PLAN.md.
See CONTRIBUTING.md. Behavior changes are judged against test262 and V8
(zig build differential-v8).
MIT, see LICENSE.