feat: add multi-engine regex flavour support
This commit is contained in:
818
engines/dotnet/Program.cs
Normal file
818
engines/dotnet/Program.cs
Normal 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..];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
26
engines/dotnet/README.md
Normal file
26
engines/dotnet/README.md
Normal file
@@ -0,0 +1,26 @@
|
||||
# .NET regex engine
|
||||
|
||||
This is a fixed `[JSExport]` bridge over .NET 10.0.10
|
||||
`System.Text.RegularExpressions`, built with SDK 10.0.302 for `browser-wasm`.
|
||||
It always applies `RegexOptions.CultureInvariant` for deterministic local
|
||||
results. Native match positions are UTF-16 offsets. The project uses
|
||||
WebAssembly library mode because its `[JSExport]` methods are hosted directly
|
||||
inside a module worker and do not require an executable entry point.
|
||||
|
||||
The bridge receives one JSON value and never evaluates user input as managed or
|
||||
JavaScript source. A nine-second native regex timeout complements the app's
|
||||
shorter killable-worker deadline. Match, capture-row, pattern, subject and
|
||||
replacement-output limits are enforced in managed code as a second boundary.
|
||||
Replacement templates are parsed once by a fixed streaming implementation of
|
||||
.NET's `$$`, numbered/named-group, whole-match, left/right-context, last-group
|
||||
and whole-input tokens. Engine startup compares that implementation with
|
||||
native `Match.Result` on a parity corpus. Successful output crosses the JS
|
||||
export as bounded UTF-8 bytes in canonical base64, avoiding unbounded
|
||||
intermediate replacement strings and JSON escaping amplification.
|
||||
|
||||
Build from the pinned SDK plus `wasm-tools` workload:
|
||||
|
||||
```sh
|
||||
/absolute/path/to/dotnet publish engines/dotnet/RegexTools.DotNet.csproj \
|
||||
--configuration Release
|
||||
```
|
||||
19
engines/dotnet/RegexTools.DotNet.csproj
Normal file
19
engines/dotnet/RegexTools.DotNet.csproj
Normal file
@@ -0,0 +1,19 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.WebAssembly">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
|
||||
<OutputType>Library</OutputType>
|
||||
<InvariantGlobalization>true</InvariantGlobalization>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<AssemblyName>RegexTools.DotNet</AssemblyName>
|
||||
<RootNamespace>RegexToolsDotNet</RootNamespace>
|
||||
<ContinuousIntegrationBuild>true</ContinuousIntegrationBuild>
|
||||
<DebugSymbols>false</DebugSymbols>
|
||||
<DebugType>none</DebugType>
|
||||
<Deterministic>true</Deterministic>
|
||||
<IncludeSourceRevisionInInformationalVersion>false</IncludeSourceRevisionInInformationalVersion>
|
||||
<PublishTrimmed>true</PublishTrimmed>
|
||||
<TrimMode>full</TrimMode>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
2
engines/dotnet/wwwroot/main.js
Normal file
2
engines/dotnet/wwwroot/main.js
Normal file
@@ -0,0 +1,2 @@
|
||||
// Fixed entry point retained by the WebAssembly SDK publish pipeline.
|
||||
// Regex Tools imports _framework/dotnet.js directly inside its engine worker.
|
||||
Reference in New Issue
Block a user