feat: add multi-engine regex flavour support

This commit is contained in:
2026-07-27 11:43:51 +02:00
parent 7079cde15f
commit 7f3a91ad37
340 changed files with 643286 additions and 483 deletions

818
engines/dotnet/Program.cs Normal file
View File

@@ -0,0 +1,818 @@
using System.Buffers;
using System.Globalization;
using System.Runtime.InteropServices.JavaScript;
using System.Text;
using System.Text.Json;
using System.Text.RegularExpressions;
public static partial class RegexBridge
{
private const int AbiVersion = 1;
private const int MaximumCaptureGroups = 1_000;
private static readonly TimeSpan MatchTimeout = TimeSpan.FromSeconds(9);
private static readonly Lazy<bool> ReplacementParity = new(
ReplacementParitySelfTest
);
[JSExport]
public static string Identity()
{
if (!ReplacementParity.Value)
throw new InvalidOperationException(
"The bounded replacement parser failed its native parity self-test."
);
return "dotnet-regex|10.0.10|System.Text.RegularExpressions|utf16|culture-invariant|replacement-parity";
}
[JSExport]
public static string Run(string requestJson)
{
try
{
using var document = JsonDocument.Parse(
requestJson,
new JsonDocumentOptions
{
AllowTrailingCommas = false,
CommentHandling = JsonCommentHandling.Disallow,
MaxDepth = 16,
}
);
var root = document.RootElement;
if (RequiredInt(root, "abi") != AbiVersion)
return Failure("bridge", "unsupported-abi", "Unsupported bridge ABI.");
var operation = RequiredString(root, "operation", 16);
if (operation is not ("execute" or "replace"))
return Failure(
"bridge",
"invalid-operation",
"Unsupported bridge operation."
);
var pattern = RequiredString(root, "pattern", 65_536);
var subject = RequiredString(root, "subject", 16 * 1024 * 1024);
var flags = RequiredString(root, "flags", 32);
var maximumMatches = RequiredInt(root, "maximumMatches");
var maximumCaptureRows = RequiredInt(root, "maximumCaptureRows");
if (
maximumMatches is < 1 or > 10_000
|| maximumCaptureRows is < 1 or > 100_000
)
return Failure(
"bridge",
"invalid-limit",
"Result limits are outside the bridge contract."
);
var replacement =
operation == "replace"
? RequiredString(root, "replacement", 65_536)
: string.Empty;
var maximumOutputBytes =
operation == "replace" ? RequiredInt(root, "maximumOutputBytes") : 1;
if (
operation == "replace"
&& maximumOutputBytes is < 1 or > 64 * 1024 * 1024
)
return Failure(
"bridge",
"invalid-limit",
"Output limit is outside the bridge contract."
);
Regex expression;
try
{
expression = new Regex(
pattern,
RegexOptionsFor(flags) | RegexOptions.CultureInvariant,
MatchTimeout
);
}
catch (RegexParseException error)
{
return Failure(
"compile",
"regex-parse-error",
error.Message,
error.Offset
);
}
catch (ArgumentException error)
{
return Failure("compile", "regex-parse-error", error.Message);
}
var numbers = expression.GetGroupNumbers();
var groupCount = numbers.Length == 0 ? 0 : numbers.Max();
if (groupCount > MaximumCaptureGroups)
return Failure(
"compile",
"capture-group-limit",
"The pattern exceeds the 1,000 capture-group bridge limit."
);
var presentNumbers = numbers.ToHashSet();
var groupNames = expression
.GetGroupNames()
.Select(name => (Name: name, Number: expression.GroupNumberFromName(name)))
.Where(item =>
item.Number > 0
&& !int.TryParse(
item.Name,
NumberStyles.None,
CultureInfo.InvariantCulture,
out _
)
)
.OrderBy(item => item.Number)
.ToArray();
ReplacementProgram? replacementProgram = null;
if (operation == "replace")
{
try
{
replacementProgram = ReplacementProgram.Parse(
replacement,
expression,
presentNumbers
);
}
catch (ArgumentException error)
{
return Failure(
"replacement",
"replacement-error",
error.Message
);
}
}
var records = new List<MatchRecord>();
var captureRows = 0;
var resultsTruncated = false;
var global = flags.Contains('g');
try
{
for (var match = expression.Match(subject); match.Success; match = match.NextMatch())
{
var rows = Math.Max(1, groupCount);
if (
records.Count >= maximumMatches
|| captureRows + rows > maximumCaptureRows
)
{
resultsTruncated = true;
break;
}
var captures = new NativeSpan?[groupCount];
for (var number = 1; number <= groupCount; number++)
{
if (!presentNumbers.Contains(number))
continue;
var group = match.Groups[number];
if (group.Success)
captures[number - 1] = new NativeSpan(
group.Index,
group.Index + group.Length
);
}
records.Add(
new MatchRecord(
new NativeSpan(match.Index, match.Index + match.Length),
captures,
match
)
);
captureRows += rows;
if (!global)
break;
}
}
catch (RegexMatchTimeoutException error)
{
return Failure("match", "match-timeout", error.Message);
}
BoundedUtf8Output? output = null;
if (operation == "replace")
{
output = new BoundedUtf8Output(maximumOutputBytes);
var sourcePosition = 0;
try
{
foreach (var record in records.OrderBy(record => record.Span.Start))
{
output.Append(
subject.AsSpan(
sourcePosition,
record.Span.Start - sourcePosition
)
);
AppendReplacement(
output,
subject,
record.Match,
replacementProgram!
);
sourcePosition = record.Span.End;
}
output.Append(subject.AsSpan(sourcePosition));
}
catch (ArgumentException error)
{
return Failure(
"replacement",
"replacement-error",
error.Message
);
}
}
return Success(
groupCount,
groupNames,
records,
resultsTruncated,
output
);
}
catch (JsonException error)
{
return Failure("bridge", "invalid-json", error.Message);
}
catch (Exception error)
{
return Failure("bridge", "bridge-error", error.Message);
}
}
private static RegexOptions RegexOptionsFor(string flags)
{
var options = RegexOptions.None;
foreach (var flag in flags)
options |= flag switch
{
'g' => RegexOptions.None,
'i' => RegexOptions.IgnoreCase,
'm' => RegexOptions.Multiline,
's' => RegexOptions.Singleline,
'n' => RegexOptions.ExplicitCapture,
'x' => RegexOptions.IgnorePatternWhitespace,
'r' => RegexOptions.RightToLeft,
'c' => RegexOptions.CultureInvariant,
'b' => RegexOptions.NonBacktracking,
_ => throw new ArgumentException($"Unsupported .NET flag: {flag}."),
};
return options;
}
private static string RequiredString(JsonElement root, string name, int maximumLength)
{
if (
!root.TryGetProperty(name, out var property)
|| property.ValueKind != JsonValueKind.String
)
throw new JsonException($"{name} must be a string.");
var value = property.GetString()!;
if (value.Length > maximumLength)
throw new JsonException($"{name} exceeds its bounded contract.");
return value;
}
private static int RequiredInt(JsonElement root, string name)
{
if (
!root.TryGetProperty(name, out var property)
|| !property.TryGetInt32(out var value)
)
throw new JsonException($"{name} must be a 32-bit integer.");
return value;
}
private static string Failure(
string phase,
string code,
string message,
int? position = null
)
{
using var stream = new MemoryStream();
using (var writer = new Utf8JsonWriter(stream))
{
writer.WriteStartObject();
writer.WriteNumber("abi", AbiVersion);
writer.WriteBoolean("ok", false);
writer.WriteString("phase", phase);
writer.WriteString("code", code);
writer.WriteString(
"message",
message.Length <= 1_024 ? message : message[..1_024]
);
if (position >= 0)
writer.WriteNumber("position", position.Value);
writer.WriteEndObject();
}
return Encoding.UTF8.GetString(stream.ToArray());
}
private static string Success(
int groupCount,
IEnumerable<(string Name, int Number)> groupNames,
IEnumerable<MatchRecord> records,
bool resultsTruncated,
BoundedUtf8Output? output
)
{
using var stream = new MemoryStream();
using (var writer = new Utf8JsonWriter(stream))
{
writer.WriteStartObject();
writer.WriteNumber("abi", AbiVersion);
writer.WriteBoolean("ok", true);
writer.WriteNumber("groupCount", groupCount);
writer.WriteStartArray("groupNames");
foreach (var group in groupNames)
{
writer.WriteStartArray();
writer.WriteNumberValue(group.Number);
writer.WriteStringValue(group.Name);
writer.WriteEndArray();
}
writer.WriteEndArray();
writer.WriteStartArray("matches");
foreach (var record in records)
{
writer.WriteStartObject();
WriteSpan(writer, "span", record.Span);
writer.WriteStartArray("captures");
foreach (var capture in record.Captures)
{
if (capture is { } span)
WriteSpan(writer, null, span);
else
writer.WriteNullValue();
}
writer.WriteEndArray();
writer.WriteEndObject();
}
writer.WriteEndArray();
writer.WriteBoolean("resultsTruncated", resultsTruncated);
if (output is not null)
{
writer.WriteBase64String("outputBase64", output.Bytes);
writer.WriteNumber("outputBytes", output.ByteCount);
writer.WriteBoolean("outputTruncated", output.Truncated);
}
writer.WriteEndObject();
}
return Encoding.UTF8.GetString(stream.ToArray());
}
private static void WriteSpan(
Utf8JsonWriter writer,
string? propertyName,
NativeSpan span
)
{
if (propertyName is null)
writer.WriteStartArray();
else
writer.WriteStartArray(propertyName);
writer.WriteNumberValue(span.Start);
writer.WriteNumberValue(span.End);
writer.WriteEndArray();
}
private readonly record struct NativeSpan(int Start, int End);
private sealed record MatchRecord(
NativeSpan Span,
NativeSpan?[] Captures,
Match Match
);
private static void AppendReplacement(
BoundedUtf8Output output,
string subject,
Match match,
ReplacementProgram replacement
)
{
foreach (var token in replacement.Tokens)
{
switch (token.Kind)
{
case ReplacementTokenKind.Literal:
output.Append(
replacement.Template.AsSpan(token.Start, token.Length)
);
break;
case ReplacementTokenKind.Group:
{
var group = match.Groups[token.GroupNumber];
if (group.Success)
output.Append(subject.AsSpan(group.Index, group.Length));
break;
}
case ReplacementTokenKind.LeftPortion:
output.Append(subject.AsSpan(0, match.Index));
break;
case ReplacementTokenKind.RightPortion:
{
var start = match.Index + match.Length;
output.Append(subject.AsSpan(start));
break;
}
case ReplacementTokenKind.LastGroup:
{
var group = match.Groups[replacement.LastGroupNumber];
if (group.Success)
output.Append(subject.AsSpan(group.Index, group.Length));
break;
}
case ReplacementTokenKind.WholeString:
output.Append(subject.AsSpan());
break;
}
}
}
private static bool ReplacementParitySelfTest()
{
try
{
var expression = new Regex(
"(?<word>a)(b)?(?<tail>c)?",
RegexOptions.CultureInvariant,
MatchTimeout
);
var presentNumbers = expression.GetGroupNumbers().ToHashSet();
var templates = new[]
{
"$2:$1/${word}/${3}/$$/$&/$`/$'/$+/$_/$99/${missing}/$",
"${1}$01-$0",
"literal",
};
for (
var match = expression.Match("zab a");
match.Success;
match = match.NextMatch()
)
{
foreach (var template in templates)
{
var replacement = ReplacementProgram.Parse(
template,
expression,
presentNumbers
);
var bounded = new BoundedUtf8Output(16 * 1024);
AppendReplacement(
bounded,
"zab a",
match,
replacement
);
if (
bounded.Truncated
|| Encoding.UTF8.GetString(bounded.Bytes)
!= match.Result(template)
)
return false;
}
}
try
{
_ = ReplacementProgram.Parse(
"$2147483648",
expression,
presentNumbers
);
return false;
}
catch (ArgumentException)
{
try
{
_ = expression.Match("a").Result("$2147483648");
return false;
}
catch (ArgumentException)
{
// Both parsers reject an overflowing numeric reference.
}
}
var largeSubject = new string('a', 256 * 1024);
var largeExpression = new Regex(
"(a+)",
RegexOptions.CultureInvariant,
MatchTimeout
);
var amplifyingTemplate = string.Concat(
Enumerable.Repeat("$1", 32_768)
);
var amplifyingProgram = ReplacementProgram.Parse(
amplifyingTemplate,
largeExpression,
largeExpression.GetGroupNumbers().ToHashSet()
);
var adversarial = new BoundedUtf8Output(5);
AppendReplacement(
adversarial,
largeSubject,
largeExpression.Match(largeSubject),
amplifyingProgram
);
return adversarial.Truncated
&& Encoding.UTF8.GetString(adversarial.Bytes) == "aaaaa";
}
catch
{
return false;
}
}
private enum ReplacementTokenKind
{
Literal,
Group,
LeftPortion,
RightPortion,
LastGroup,
WholeString,
}
private readonly record struct ReplacementToken(
ReplacementTokenKind Kind,
int Start = 0,
int Length = 0,
int GroupNumber = 0
);
private sealed record ReplacementProgram(
string Template,
ReplacementToken[] Tokens,
int LastGroupNumber
)
{
public static ReplacementProgram Parse(
string template,
Regex expression,
HashSet<int> presentNumbers
)
{
var tokens = new List<ReplacementToken>();
var literalStart = 0;
var cursor = 0;
void AddLiteral(int end)
{
if (end > literalStart)
tokens.Add(
new ReplacementToken(
ReplacementTokenKind.Literal,
literalStart,
end - literalStart
)
);
}
void AddRecognized(
int dollar,
int end,
ReplacementToken token
)
{
AddLiteral(dollar);
tokens.Add(token);
cursor = end;
literalStart = end;
}
while (cursor < template.Length)
{
var dollar = template.IndexOf('$', cursor);
if (dollar < 0)
break;
if (dollar + 1 >= template.Length)
{
cursor = dollar + 1;
continue;
}
var next = template[dollar + 1];
if (next == '$')
{
AddRecognized(
dollar,
dollar + 2,
new ReplacementToken(
ReplacementTokenKind.Literal,
dollar + 1,
1
)
);
continue;
}
if (next == '&')
{
AddRecognized(
dollar,
dollar + 2,
new ReplacementToken(
ReplacementTokenKind.Group,
GroupNumber: 0
)
);
continue;
}
if (next == '`')
{
AddRecognized(
dollar,
dollar + 2,
new ReplacementToken(
ReplacementTokenKind.LeftPortion
)
);
continue;
}
if (next == '\'')
{
AddRecognized(
dollar,
dollar + 2,
new ReplacementToken(
ReplacementTokenKind.RightPortion
)
);
continue;
}
if (next == '+')
{
AddRecognized(
dollar,
dollar + 2,
new ReplacementToken(
ReplacementTokenKind.LastGroup
)
);
continue;
}
if (next == '_')
{
AddRecognized(
dollar,
dollar + 2,
new ReplacementToken(
ReplacementTokenKind.WholeString
)
);
continue;
}
var angled = next == '{' && dollar + 2 < template.Length;
var referenceStart = dollar + (angled ? 2 : 1);
if (
referenceStart < template.Length
&& template[referenceStart] is >= '0' and <= '9'
)
{
var number = 0;
var end = referenceStart;
while (
end < template.Length
&& template[end] is >= '0' and <= '9'
)
{
var digit = template[end] - '0';
if (
number > int.MaxValue / 10
|| (
number == int.MaxValue / 10
&& digit > int.MaxValue % 10
)
)
throw new ArgumentException(
"A replacement capture number exceeds Int32.MaxValue."
);
number = number * 10 + digit;
end++;
}
var syntaxComplete =
!angled
|| (
end < template.Length
&& template[end] == '}'
);
if (syntaxComplete && presentNumbers.Contains(number))
{
AddRecognized(
dollar,
angled ? end + 1 : end,
new ReplacementToken(
ReplacementTokenKind.Group,
GroupNumber: number
)
);
continue;
}
}
else if (
angled
&& referenceStart < template.Length
&& IsBoundaryWordCharacter(template[referenceStart])
)
{
var end = referenceStart + 1;
while (
end < template.Length
&& IsBoundaryWordCharacter(template[end])
)
end++;
if (end < template.Length && template[end] == '}')
{
var name = template[
referenceStart..end
];
var number = expression.GroupNumberFromName(name);
if (number >= 0)
{
AddRecognized(
dollar,
end + 1,
new ReplacementToken(
ReplacementTokenKind.Group,
GroupNumber: number
)
);
continue;
}
}
}
cursor = dollar + 1;
}
AddLiteral(template.Length);
var numbers = expression.GetGroupNumbers();
return new ReplacementProgram(
template,
tokens.ToArray(),
numbers.Length == 0 ? 0 : numbers[^1]
);
}
private static bool IsBoundaryWordCharacter(char value)
{
if (value is '\u200c' or '\u200d')
return true;
return CharUnicodeInfo.GetUnicodeCategory(value) switch
{
UnicodeCategory.UppercaseLetter => true,
UnicodeCategory.LowercaseLetter => true,
UnicodeCategory.TitlecaseLetter => true,
UnicodeCategory.ModifierLetter => true,
UnicodeCategory.OtherLetter => true,
UnicodeCategory.NonSpacingMark => true,
UnicodeCategory.DecimalDigitNumber => true,
UnicodeCategory.ConnectorPunctuation => true,
_ => false,
};
}
}
private sealed class BoundedUtf8Output(int maximumBytes)
{
private readonly ArrayBufferWriter<byte> _value = new();
public int ByteCount => _value.WrittenCount;
public bool Truncated { get; private set; }
public ReadOnlySpan<byte> Bytes => _value.WrittenSpan;
public void Append(ReadOnlySpan<char> value)
{
if (Truncated)
return;
while (!value.IsEmpty)
{
var status = Rune.DecodeFromUtf16(value, out var rune, out var consumed);
if (status != OperationStatus.Done)
{
rune = Rune.ReplacementChar;
consumed = 1;
}
var width = rune.Utf8SequenceLength;
if (ByteCount + width > maximumBytes)
{
Truncated = true;
return;
}
var destination = _value.GetSpan(width);
var written = rune.EncodeToUtf8(destination);
_value.Advance(written);
value = value[consumed..];
}
}
}
}