819 lines
28 KiB
C#
819 lines
28 KiB
C#
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..];
|
|
}
|
|
}
|
|
}
|
|
}
|