Introduction
Corvus.Regex.Ecma262 is a regular expression engine that answers as ECMAScript does. Where System.Text.RegularExpressions has its own grammar and its own meaning for \d, \w, \s, ., case folding and Unicode properties, this engine has ECMA-262's, so a pattern written for JavaScript, or taken from a JSON Schema, matches the same texts here as it does there.
This guide covers:
- Setup — the one package, and what it brings.
- Patterns and flags — the flags are ECMA-262's letters, not
RegexOptions, and what each one does. - Matching — the members you know from
System.Text.RegularExpressions, and the allocation-free forms beside them. - UTF-8 — the same patterns over
ReadOnlySpan<byte>with no transcoding. - The source generator —
[GeneratedRegex]and what it generates. - Differences from .NET's Regex — what to expect when the two meet.
Setup
One package carries the library and the source generator:
dotnet add package Corvus.Regex.Ecma262
The library targets net10.0 and netstandard2.0. The vectorised searches and the generated search code need the .NET 10 or later runtime. The netstandard2.0 build is for .NET Framework, where the same patterns give the same answers by the plain paths. .NET 8 and .NET 9 are not supported.
using Corvus.Ecma262.RegularExpressions;
Patterns and flags
A pattern is written as it would be in JavaScript, and the flags are the letters RegExp takes, as a string or as the RegexFlags enumeration:
| Flag | RegexFlags |
Meaning |
|---|---|---|
d |
HasIndices |
Accepted for completeness; indices are always kept. |
g |
Global |
Replace replaces every match; without it, the first, as String.prototype.replace does. |
i |
IgnoreCase |
Canonicalize of the mode in force: simple case folding with u or v, toUpperCase without. Never the culture. |
m |
Multiline |
^ and $ at line terminators. |
s |
DotAll |
The dot matches line terminators. |
u |
Unicode |
The u flag grammar; matched by code point. |
v |
UnicodeSets |
Set notation and properties of strings; matched by code point. u and v together is a syntax error, as in ECMA-262. |
y |
Sticky |
A match only at the start position. |
Regex a = new(@"\p{Script=Greek}+", "u");
Regex b = new(@"[[a-z]--[aeiou]]+", RegexFlags.UnicodeSets);
Regex c = new(@"^(?<year>\d{4})-(?<month>\d{2})$", "u");
With no flag the pattern is read with the Annex B grammar and matched by UTF-16 code unit, which is what ECMA-262 does with no flag. JSON Schema's reading, the u flag first and Annex B when that refuses, is two calls of Regex.TryCreate, which costs no exception:
if (!Regex.TryCreate(pattern, RegexFlags.Unicode, out Regex? regex, out string? error)
&& !Regex.TryCreate(pattern, RegexFlags.None, out regex, out error))
{
throw new InvalidOperationException(error);
}
A pattern that is not valid throws RegexParseException, with the reason and the offset. Every pattern has deterministic limits of size, and RegexPatternTooLargeException says when one is passed.
Matching
The members are the ones of System.Text.RegularExpressions.Regex, with the same names and the same shapes:
Regex regex = new(@"(?<word>\w+)@(?<host>\w+)\.\w+", "gu");
bool any = regex.IsMatch("mail sherlock@baker.st today");
Match m = regex.Match("mail sherlock@baker.st today");
string host = m.Groups["host"].Value; // "baker"
MatchCollection all = regex.Matches("a@b.c and d@e.f"); // 2 matches
string replaced = regex.Replace("a@b.c", "$<word> at $<host>"); // "a at b"
string?[] parts = regex.Split("x a@b.c y");
Match, Groups, Matches, Replace with a string and Split with a string allocate what they return, as .NET's do: that is the drop-in shape. The span members beside them allocate nothing:
// Every match, with the groups into a span the caller gives.
Span<ValueCapture> groups = stackalloc ValueCapture[regex.GroupCount + 1];
foreach (ValueMatch match in regex.EnumerateMatches(text.AsSpan(), groups))
{
ReadOnlySpan<char> host = text.AsSpan(groups[2].Index, groups[2].Length);
}
// One match from a position.
if (regex.TryMatch(text.AsSpan(), 0, groups, out ValueMatch first)) { }
// Replace into a span, or into a buffer writer.
Span<char> buffer = stackalloc char[256];
if (regex.TryReplace(text.AsSpan(), "$<word>".AsSpan(), buffer, out int written)) { }
regex.Replace(text.AsSpan(), "$<word>".AsSpan(), new ArrayBufferWriter<char>());
// Split without the array.
foreach (Range piece in regex.EnumerateSplits(text.AsSpan())) { }
int n = regex.Count(text);
Matches, EnumerateMatches and Count are the global search whether or not g is set. Where ECMA-262 keeps lastIndex on the RegExp, the library takes the position as an argument: Match(input, startAt) is exec with lastIndex set, and y anchors it there. A Regex has no mutable state, so one instance is safe to share between threads.
A match that reaches its limit of time (MatchTimeout) or of memory throws RegexMatchTimeoutException. Patterns with no backreference run in time proportional to the pattern times the text, and take no timeout.
UTF-8
Every member that takes a ReadOnlySpan<char> has a twin that takes UTF-8 bytes, and the engine matches the bytes as they are. Nothing is transcoded, and the positions it gives are byte offsets.
Regex regex = new(@"\p{L}+", "gu");
ReadOnlySpan<byte> utf8 = "Шерлок Холмс"u8;
int words = regex.Count(utf8); // 2
foreach (ValueMatch m in regex.EnumerateMatches(utf8))
{
ReadOnlySpan<byte> word = utf8.Slice(m.Index, m.Length); // the bytes of one word
}
ArrayBufferWriter<byte> output = new();
regex.Replace(utf8, "_"u8, output); // "_ _"
Under the u and v flags a character is a code point in both encodings, so a class, a dot or a quantifier takes a four-byte sequence as one character. With no flag ECMA-262 matches by UTF-16 code unit; over UTF-8 the engine keeps that meaning, so a pattern with no flag gives the same answers over the bytes of a text as over its chars, except where the text holds a lone surrogate, which UTF-8 cannot hold.
The source generator
[GeneratedRegex] on a partial method or a partial property writes the matcher as code at build time, in the shape of .NET's own attribute (a partial property needs C# 13, which net10.0 has by default; on netstandard2.0 set LangVersion to 13 or use a method). The pattern and the flags are checked by the generator: a pattern that is not valid, a flag that is not one, or u with v, is a build error with the reason and the position.
public static partial class Patterns
{
[GeneratedRegex(@"\b\d{1,2}/\d{1,2}/\d{2,4}\b", "gu")]
public static partial Regex Dates();
[GeneratedRegex(@"(?<word>\w+)", RegexFlags.Global | RegexFlags.Unicode | RegexFlags.IgnoreCase, 100)]
public static partial Regex Words { get; }
}
int n = Patterns.Dates().Count("on 11/18/2019 and 1/2/03"); // 2
The member returns one Regex each time, built on the generated code. The generated code is the search for where a match can start and the match itself as structured code, over both encodings. A pattern too large for straight-line code (a class of thousands of strings, such as \p{RGI_Emoji}) is handed to the interpreter by its generated runner, so every pattern compiles.
The generator ships in the same package and runs on the netstandard2.0 build of the library, as a source generator must.
Differences from .NET's Regex
The surface follows System.Text.RegularExpressions; the meaning follows ECMA-262. The differences a user coming from .NET will meet:
- The grammar. No
(?i)inline option (ECMA-262 has(?i:...)groups), no atomic groups, no possessive quantifiers, no\Aand\z, no conditionals, no balancing groups.\zis the letterzunder Annex B and an error underu. - The character classes.
\dis[0-9],\wis the 63 ASCII word characters,\sis ECMAScript's white space and line terminators, and.excludes the four line terminators (LF, CR, U+2028, U+2029). - Case. With
iandu, simple case folding (soſmatchessandKmatchesk); withiand no flag,toUpperCaseof one character. Never the current culture. - Captures. ECMA-262 keeps only a group's last capture under a quantifier, so
Group.Captureshas one entry. - Positions. Indices are in the units of the text matched: UTF-16 code units over strings, bytes over UTF-8.
- Timeouts. Patterns without backreferences are linear and take no timeout; a timeout applies to the others, and the memory a match may use is bounded as well.