diff --git a/samples/ZaString.Demo/ZaString.Demo.csproj b/samples/ZaString.Demo/ZaString.Demo.csproj index 495db8e5..79f19a9d 100644 --- a/samples/ZaString.Demo/ZaString.Demo.csproj +++ b/samples/ZaString.Demo/ZaString.Demo.csproj @@ -2,7 +2,7 @@ Exe - net8.0 + net10.0 enable enable false diff --git a/src/ZaString/Core/ZaPooledStringBuilder.cs b/src/ZaString/Core/ZaPooledStringBuilder.cs index c4cbf393..6ea19d14 100644 --- a/src/ZaString/Core/ZaPooledStringBuilder.cs +++ b/src/ZaString/Core/ZaPooledStringBuilder.cs @@ -1,6 +1,7 @@ using System.Buffers; using System.Globalization; using System.Text; +using ZaString.Escaping; namespace ZaString.Core; @@ -94,6 +95,31 @@ public void RemoveLast(int count) Length -= count; } + /// + /// Reserves a writable span of the specified size, growing the rented buffer if needed. + /// Call with the number of characters written to commit the append. + /// + public ZaPooledStringBuilder GetAppendSpan(int size, out Span writeSpan) + { + ThrowIfDisposed(); + ArgumentOutOfRangeException.ThrowIfNegative(size); + + EnsureCapacity(size); + writeSpan = _buffer.AsSpan(Length, size); + return this; + } + + public void Advance(int count) + { + ThrowIfDisposed(); + if ((uint)count > (uint)(_buffer.Length - Length)) + { + throw new ArgumentOutOfRangeException(nameof(count)); + } + + Length += count; + } + /// /// Gets or sets the character at the specified index. /// @@ -233,6 +259,51 @@ public ZaPooledStringBuilder Append(bool value) return Append(value ? "true" : "false"); } + public ZaPooledStringBuilder AppendJsonEscaped(ReadOnlySpan value) + { + var required = JsonEscapeStrategy.GetEscapedLength(value); + GetAppendSpan(required, out var destination); + JsonEscapeStrategy.TryEscape(value, destination, out var written); + Advance(written); + return this; + } + + public ZaPooledStringBuilder AppendHtmlEscaped(ReadOnlySpan value) + { + var required = HtmlEscapeStrategy.GetEscapedLength(value); + GetAppendSpan(required, out var destination); + HtmlEscapeStrategy.TryEscape(value, destination, out var written); + Advance(written); + return this; + } + + public ZaPooledStringBuilder AppendCsvEscaped(ReadOnlySpan value) + { + var required = CsvEscapeStrategy.GetEscapedLength(value); + GetAppendSpan(required, out var destination); + CsvEscapeStrategy.TryEscape(value, destination, out var written); + Advance(written); + return this; + } + + public ZaPooledStringBuilder AppendUrlEncoded(ReadOnlySpan value) + { + var required = UrlEscapeStrategy.GetEscapedLength(value); + GetAppendSpan(required, out var destination); + UrlEscapeStrategy.TryEscape(value, destination, out var written); + Advance(written); + return this; + } + + public ZaPooledStringBuilder AppendFormUrlEncoded(ReadOnlySpan value) + { + var required = FormUrlEscapeStrategy.GetEscapedLength(value); + GetAppendSpan(required, out var destination); + FormUrlEscapeStrategy.TryEscape(value, destination, out var written); + Advance(written); + return this; + } + public ZaPooledStringBuilder Append(T value, ReadOnlySpan format = default, IFormatProvider? provider = null) where T : ISpanFormattable { ThrowIfDisposed(); diff --git a/src/ZaString/Escaping/CsvEscapeStrategy.cs b/src/ZaString/Escaping/CsvEscapeStrategy.cs new file mode 100644 index 00000000..6e499991 --- /dev/null +++ b/src/ZaString/Escaping/CsvEscapeStrategy.cs @@ -0,0 +1,75 @@ +namespace ZaString.Escaping; + +/// +/// Provides span-to-span CSV field escaping without intermediate string allocation. +/// +public static class CsvEscapeStrategy +{ + public static int GetEscapedLength(ReadOnlySpan value) + { + if (!NeedsQuoting(value)) + { + return value.Length; + } + + var quoteCount = 0; + foreach (var c in value) + { + if (c == '"') + { + quoteCount++; + } + } + + return value.Length + quoteCount + 2; + } + + public static bool TryEscape(ReadOnlySpan value, Span destination, out int written) + { + var required = GetEscapedLength(value); + if (required > destination.Length) + { + written = 0; + return false; + } + + written = Escape(value, destination); + return true; + } + + private static int Escape(ReadOnlySpan value, Span destination) + { + if (!NeedsQuoting(value)) + { + value.CopyTo(destination); + return value.Length; + } + + var w = 0; + destination[w++] = '"'; + foreach (var c in value) + { + destination[w++] = c; + if (c == '"') + { + destination[w++] = '"'; + } + } + + destination[w++] = '"'; + return w; + } + + private static bool NeedsQuoting(ReadOnlySpan value) + { + if (value.Length == 0) return true; + if (char.IsWhiteSpace(value[0]) || char.IsWhiteSpace(value[^1])) return true; + + foreach (var c in value) + { + if (c is ',' or '"' or '\n' or '\r') return true; + } + + return false; + } +} diff --git a/src/ZaString/Escaping/FormUrlEscapeStrategy.cs b/src/ZaString/Escaping/FormUrlEscapeStrategy.cs new file mode 100644 index 00000000..602df009 --- /dev/null +++ b/src/ZaString/Escaping/FormUrlEscapeStrategy.cs @@ -0,0 +1,100 @@ +namespace ZaString.Escaping; + +/// +/// Provides span-to-span form URL encoding without intermediate string allocation. +/// +public static class FormUrlEscapeStrategy +{ + public static int GetEscapedLength(ReadOnlySpan value) + { + var length = 0; + for (var i = 0; i < value.Length; i++) + { + var c = value[i]; + if (c == ' ') + { + length += 1; + } + else if (c <= 0x7F) + { + length += UrlEscapeStrategy.IsUnreservedAscii(c) ? 1 : 3; + } + else if (char.IsHighSurrogate(c) && i + 1 < value.Length && char.IsLowSurrogate(value[i + 1])) + { + length += 4 * 3; + i++; + } + else + { + length += 9; + } + } + + return length; + } + + public static bool TryEscape(ReadOnlySpan value, Span destination, out int written) + { + var required = GetEscapedLength(value); + if (required > destination.Length) + { + written = 0; + return false; + } + + written = Escape(value, destination); + return true; + } + + private static int Escape(ReadOnlySpan value, Span destination) + { + var w = 0; + for (var i = 0; i < value.Length; i++) + { + var c = value[i]; + if (c == ' ') + { + destination[w++] = '+'; + } + else if (c <= 0x7F) + { + if (UrlEscapeStrategy.IsUnreservedAscii(c)) + { + destination[w++] = c; + } + else + { + destination[w++] = '%'; + UrlEscapeStrategy.WriteHexByte((byte)c, destination.Slice(w, 2)); + w += 2; + } + } + else if (char.IsHighSurrogate(c) && i + 1 < value.Length && char.IsLowSurrogate(value[i + 1])) + { + var low = value[++i]; + var codePoint = 0x10000 + (c - 0xD800 << 10 | low - 0xDC00); + w += UrlEscapeStrategy.PercentEncodeUtf8FromCodePoint(codePoint, destination[w..]); + } + else + { + w += WriteReplacementChar(destination[w..]); + } + } + + return w; + } + + private static int WriteReplacementChar(Span destination) + { + destination[0] = '%'; + destination[1] = 'E'; + destination[2] = 'F'; + destination[3] = '%'; + destination[4] = 'B'; + destination[5] = 'F'; + destination[6] = '%'; + destination[7] = 'B'; + destination[8] = 'D'; + return 9; + } +} diff --git a/src/ZaString/Escaping/HtmlEscapeStrategy.cs b/src/ZaString/Escaping/HtmlEscapeStrategy.cs new file mode 100644 index 00000000..877ef1fe --- /dev/null +++ b/src/ZaString/Escaping/HtmlEscapeStrategy.cs @@ -0,0 +1,88 @@ +namespace ZaString.Escaping; + +/// +/// Provides span-to-span HTML escaping without intermediate string allocation. +/// +public static class HtmlEscapeStrategy +{ + public static int GetEscapedLength(ReadOnlySpan value) + { + var extra = 0; + foreach (var c in value) + { + switch (c) + { + case '&': extra += 4; break; + case '<': + case '>': extra += 3; break; + case '"': extra += 5; break; + case '\'': extra += 4; break; + } + } + + return value.Length + extra; + } + + public static bool TryEscape(ReadOnlySpan value, Span destination, out int written) + { + var required = GetEscapedLength(value); + if (required > destination.Length) + { + written = 0; + return false; + } + + written = Escape(value, destination); + return true; + } + + private static int Escape(ReadOnlySpan value, Span destination) + { + var w = 0; + foreach (var c in value) + { + switch (c) + { + case '&': + destination[w++] = '&'; + destination[w++] = 'a'; + destination[w++] = 'm'; + destination[w++] = 'p'; + destination[w++] = ';'; + break; + case '<': + destination[w++] = '&'; + destination[w++] = 'l'; + destination[w++] = 't'; + destination[w++] = ';'; + break; + case '>': + destination[w++] = '&'; + destination[w++] = 'g'; + destination[w++] = 't'; + destination[w++] = ';'; + break; + case '"': + destination[w++] = '&'; + destination[w++] = 'q'; + destination[w++] = 'u'; + destination[w++] = 'o'; + destination[w++] = 't'; + destination[w++] = ';'; + break; + case '\'': + destination[w++] = '&'; + destination[w++] = '#'; + destination[w++] = '3'; + destination[w++] = '9'; + destination[w++] = ';'; + break; + default: + destination[w++] = c; + break; + } + } + + return w; + } +} diff --git a/src/ZaString/Escaping/JsonEscapeStrategy.cs b/src/ZaString/Escaping/JsonEscapeStrategy.cs new file mode 100644 index 00000000..3bc363be --- /dev/null +++ b/src/ZaString/Escaping/JsonEscapeStrategy.cs @@ -0,0 +1,136 @@ +namespace ZaString.Escaping; + +/// +/// Provides span-to-span JSON escaping without intermediate string allocation. +/// +public static class JsonEscapeStrategy +{ + public static int GetEscapedLength(ReadOnlySpan value) + { + var extra = 0; + foreach (var c in value) + { + switch (c) + { + case '"': + case '\\': + case '\b': + case '\f': + case '\n': + case '\r': + case '\t': + extra += 1; + break; + case '\u2028': + case '\u2029': + extra += 5; + break; + + default: + if (c < ' ') + { + extra += 5; + } + + break; + } + } + + return value.Length + extra; + } + + public static bool TryEscape(ReadOnlySpan value, Span destination, out int written) + { + var required = GetEscapedLength(value); + if (required > destination.Length) + { + written = 0; + return false; + } + + written = Escape(value, destination); + return true; + } + + private static int Escape(ReadOnlySpan value, Span destination) + { + var w = 0; + for (var i = 0; i < value.Length; i++) + { + var c = value[i]; + switch (c) + { + case '"': + destination[w++] = '\\'; + destination[w++] = '"'; + break; + case '\\': + destination[w++] = '\\'; + destination[w++] = '\\'; + break; + case '\b': + destination[w++] = '\\'; + destination[w++] = 'b'; + break; + case '\f': + destination[w++] = '\\'; + destination[w++] = 'f'; + break; + case '\n': + destination[w++] = '\\'; + destination[w++] = 'n'; + break; + case '\r': + destination[w++] = '\\'; + destination[w++] = 'r'; + break; + case '\t': + destination[w++] = '\\'; + destination[w++] = 't'; + break; + case '\u2028': + destination[w++] = '\\'; + destination[w++] = 'u'; + destination[w++] = '2'; + destination[w++] = '0'; + destination[w++] = '2'; + destination[w++] = '8'; + break; + case '\u2029': + destination[w++] = '\\'; + destination[w++] = 'u'; + destination[w++] = '2'; + destination[w++] = '0'; + destination[w++] = '2'; + destination[w++] = '9'; + break; + + default: + if (c < ' ') + { + destination[w++] = '\\'; + destination[w++] = 'u'; + destination[w++] = '0'; + destination[w++] = '0'; + WriteHexByte((byte)c, destination.Slice(w, 2)); + w += 2; + } + else + { + destination[w++] = c; + } + + break; + } + } + + return w; + } + + private static void WriteHexByte(byte value, Span destination) + { + const string hex = "0123456789ABCDEF"; + destination[0] = hex[value >> 4 & 0xF]; + destination[1] = hex[value & 0xF]; + } +} diff --git a/src/ZaString/Escaping/UrlEscapeStrategy.cs b/src/ZaString/Escaping/UrlEscapeStrategy.cs new file mode 100644 index 00000000..af863728 --- /dev/null +++ b/src/ZaString/Escaping/UrlEscapeStrategy.cs @@ -0,0 +1,160 @@ +namespace ZaString.Escaping; + +/// +/// Provides span-to-span URL percent encoding without intermediate string allocation. +/// +public static class UrlEscapeStrategy +{ + public static int GetEscapedLength(ReadOnlySpan value) + { + var length = 0; + for (var i = 0; i < value.Length; i++) + { + var c = value[i]; + if (c <= 0x7F) + { + length += IsUnreservedAscii(c) ? 1 : 3; + } + else if (char.IsHighSurrogate(c) && i + 1 < value.Length && char.IsLowSurrogate(value[i + 1])) + { + length += 4 * 3; + i++; + } + else + { + length += char.IsSurrogate(c) ? 9 : c <= 0x7FF ? 2 * 3 : 3 * 3; + } + } + + return length; + } + + public static bool TryEscape(ReadOnlySpan value, Span destination, out int written) + { + var required = GetEscapedLength(value); + if (required > destination.Length) + { + written = 0; + return false; + } + + written = Escape(value, destination); + return true; + } + + internal static bool IsUnreservedAscii(char c) + { + return c is >= 'A' and <= 'Z' or >= 'a' and <= 'z' or >= '0' and <= '9' or '-' or '_' or '.' or '~'; + } + + internal static void WriteHexByte(byte value, Span destination) + { + const string hex = "0123456789ABCDEF"; + destination[0] = hex[value >> 4 & 0xF]; + destination[1] = hex[value & 0xF]; + } + + internal static int PercentEncodeUtf8FromCodePoint(int codePoint, Span destination) + { + switch (codePoint) + { + case <= 0x7F: + destination[0] = '%'; + WriteHexByte((byte)codePoint, destination.Slice(1, 2)); + return 3; + + case <= 0x7FF: + { + var b1 = (byte)(0b1100_0000 | codePoint >> 6); + var b2 = (byte)(0b1000_0000 | codePoint & 0b0011_1111); + destination[0] = '%'; + WriteHexByte(b1, destination.Slice(1, 2)); + destination[3] = '%'; + WriteHexByte(b2, destination.Slice(4, 2)); + return 6; + } + + case <= 0xFFFF: + { + var b1 = (byte)(0b1110_0000 | codePoint >> 12); + var b2 = (byte)(0b1000_0000 | codePoint >> 6 & 0b0011_1111); + var b3 = (byte)(0b1000_0000 | codePoint & 0b0011_1111); + destination[0] = '%'; + WriteHexByte(b1, destination.Slice(1, 2)); + destination[3] = '%'; + WriteHexByte(b2, destination.Slice(4, 2)); + destination[6] = '%'; + WriteHexByte(b3, destination.Slice(7, 2)); + return 9; + } + + default: + { + var b1 = (byte)(0b1111_0000 | codePoint >> 18); + var b2 = (byte)(0b1000_0000 | codePoint >> 12 & 0b0011_1111); + var b3 = (byte)(0b1000_0000 | codePoint >> 6 & 0b0011_1111); + var b4 = (byte)(0b1000_0000 | codePoint & 0b0011_1111); + destination[0] = '%'; + WriteHexByte(b1, destination.Slice(1, 2)); + destination[3] = '%'; + WriteHexByte(b2, destination.Slice(4, 2)); + destination[6] = '%'; + WriteHexByte(b3, destination.Slice(7, 2)); + destination[9] = '%'; + WriteHexByte(b4, destination.Slice(10, 2)); + return 12; + } + } + } + + private static int Escape(ReadOnlySpan value, Span destination) + { + var w = 0; + for (var i = 0; i < value.Length; i++) + { + var c = value[i]; + if (c <= 0x7F) + { + if (IsUnreservedAscii(c)) + { + destination[w++] = c; + } + else + { + destination[w++] = '%'; + WriteHexByte((byte)c, destination.Slice(w, 2)); + w += 2; + } + } + else if (char.IsHighSurrogate(c) && i + 1 < value.Length && char.IsLowSurrogate(value[i + 1])) + { + var low = value[++i]; + var codePoint = 0x10000 + (c - 0xD800 << 10 | low - 0xDC00); + w += PercentEncodeUtf8FromCodePoint(codePoint, destination[w..]); + } + else + { + var codePoint = (int)c; + w += char.IsSurrogate(c) + ? WriteReplacementChar(destination[w..]) + : PercentEncodeUtf8FromCodePoint(codePoint, destination[w..]); + } + } + + return w; + } + + private static int WriteReplacementChar(Span destination) + { + destination[0] = '%'; + destination[1] = 'E'; + destination[2] = 'F'; + destination[3] = '%'; + destination[4] = 'B'; + destination[5] = 'F'; + destination[6] = '%'; + destination[7] = 'B'; + destination[8] = 'D'; + return 9; + } +} diff --git a/src/ZaString/Extensions/ZaSpanStringBuilderExtensions.cs b/src/ZaString/Extensions/ZaSpanStringBuilderExtensions.cs index 0d0697fb..23835925 100644 --- a/src/ZaString/Extensions/ZaSpanStringBuilderExtensions.cs +++ b/src/ZaString/Extensions/ZaSpanStringBuilderExtensions.cs @@ -2,6 +2,7 @@ using System.Globalization; using System.Runtime.CompilerServices; using ZaString.Core; +using ZaString.Escaping; namespace ZaString.Extensions; @@ -663,139 +664,17 @@ public static ref ZaSpanStringBuilder AppendJsonEscaped(ref this ZaSpanStringBui public static bool TryAppendJsonEscaped(ref this ZaSpanStringBuilder builder, ReadOnlySpan value) { - var needsEscape = value.IndexOfAny("\"\\\b\f\n\r\t".AsSpan()) >= 0; - if (!needsEscape) - { - for (int i = 0; i < value.Length; i++) - { - if (value[i] < ' ' || value[i] is '\u2028' or '\u2029') - { - needsEscape = true; - break; - } - } - } - - if (!needsEscape) - { - return builder.TryAppend(value); - } - - var required = GetJsonEscapedLength(value); + var required = JsonEscapeStrategy.GetEscapedLength(value); if (required > builder.RemainingSpan.Length) { return false; } - var dest = builder.RemainingSpan; - var w = 0; - for (var i = 0; i < value.Length; i++) - { - var c = value[i]; - switch (c) - { - case '"': - dest[w++] = '\\'; - dest[w++] = '"'; - break; - case '\\': - dest[w++] = '\\'; - dest[w++] = '\\'; - break; - case '\b': - dest[w++] = '\\'; - dest[w++] = 'b'; - break; - case '\f': - dest[w++] = '\\'; - dest[w++] = 'f'; - break; - case '\n': - dest[w++] = '\\'; - dest[w++] = 'n'; - break; - case '\r': - dest[w++] = '\\'; - dest[w++] = 'r'; - break; - case '\t': - dest[w++] = '\\'; - dest[w++] = 't'; - break; - case '\u2028': - dest[w++] = '\\'; - dest[w++] = 'u'; - dest[w++] = '2'; - dest[w++] = '0'; - dest[w++] = '2'; - dest[w++] = '8'; - break; - case '\u2029': - dest[w++] = '\\'; - dest[w++] = 'u'; - dest[w++] = '2'; - dest[w++] = '0'; - dest[w++] = '2'; - dest[w++] = '9'; - break; - - default: - if (c < ' ') - { - dest[w++] = '\\'; - dest[w++] = 'u'; - dest[w++] = '0'; - dest[w++] = '0'; - WriteHexByte((byte)c, dest.Slice(w, 2)); - w += 2; - } - else - { - dest[w++] = c; - } - - break; - } - } - - builder.Advance(required); + JsonEscapeStrategy.TryEscape(value, builder.RemainingSpan, out var written); + builder.Advance(written); return true; } - private static int GetJsonEscapedLength(ReadOnlySpan value) - { - var extra = 0; - foreach (var c in value) - { - switch (c) - { - case '"': - case '\\': - case '\b': - case '\f': - case '\n': - case '\r': - case '\t': - extra += 1; - break; - case '\u2028': - case '\u2029': - extra += 5; - break; - - default: - if (c < ' ') - { - extra += 5; - } - - break; - } - } - - return value.Length + extra; - } - private static void WriteHexByte(byte b, Span dest) { const string hex = "0123456789ABCDEF"; @@ -821,83 +700,17 @@ public static ref ZaSpanStringBuilder AppendHtmlEscaped(ref this ZaSpanStringBui public static bool TryAppendHtmlEscaped(ref this ZaSpanStringBuilder builder, ReadOnlySpan value) { - if (value.IndexOfAny("&<>\"'".AsSpan()) < 0) - { - return builder.TryAppend(value); - } - - var required = GetHtmlEscapedLength(value); + var required = HtmlEscapeStrategy.GetEscapedLength(value); if (required > builder.RemainingSpan.Length) { return false; } - var dest = builder.RemainingSpan; - var w = 0; - foreach (var t in value) - { - switch (t) - { - case '&': - dest[w++] = '&'; - dest[w++] = 'a'; - dest[w++] = 'm'; - dest[w++] = 'p'; - dest[w++] = ';'; - break; - case '<': - dest[w++] = '&'; - dest[w++] = 'l'; - dest[w++] = 't'; - dest[w++] = ';'; - break; - case '>': - dest[w++] = '&'; - dest[w++] = 'g'; - dest[w++] = 't'; - dest[w++] = ';'; - break; - case '"': - dest[w++] = '&'; - dest[w++] = 'q'; - dest[w++] = 'u'; - dest[w++] = 'o'; - dest[w++] = 't'; - dest[w++] = ';'; - break; - case '\'': - dest[w++] = '&'; - dest[w++] = '#'; - dest[w++] = '3'; - dest[w++] = '9'; - dest[w++] = ';'; - break; - default: dest[w++] = t; break; - } - } - - builder.Advance(required); + HtmlEscapeStrategy.TryEscape(value, builder.RemainingSpan, out var written); + builder.Advance(written); return true; } - private static int GetHtmlEscapedLength(ReadOnlySpan value) - { - var extra = 0; - foreach (var t in value) - { - switch (t) - { - case '&': extra += 4; break; - case '<': - case '>': extra += 3; break; - case '"': extra += 5; break; - case '\'': extra += 4; break; - } - } - - return value.Length + extra; - } - public static ref ZaSpanStringBuilder AppendCsvEscaped(ref this ZaSpanStringBuilder builder, ReadOnlySpan value) { if (!TryAppendCsvEscaped(ref builder, value)) @@ -910,52 +723,17 @@ public static ref ZaSpanStringBuilder AppendCsvEscaped(ref this ZaSpanStringBuil public static bool TryAppendCsvEscaped(ref this ZaSpanStringBuilder builder, ReadOnlySpan value) { - var needsQuote = NeedsCsvQuoting(value); - if (!needsQuote) - { - return builder.TryAppend(value); - } - - var quoteCount = 0; - foreach (var t in value) - if (t == '"') - quoteCount++; - - var required = value.Length + quoteCount + 2; + var required = CsvEscapeStrategy.GetEscapedLength(value); if (required > builder.RemainingSpan.Length) { return false; } - var dest = builder.RemainingSpan; - var w = 0; - dest[w++] = '"'; - foreach (var c in value) - { - dest[w++] = c; - if (c == '"') - { - dest[w++] = '"'; - } - } - - dest[w] = '"'; - builder.Advance(required); + CsvEscapeStrategy.TryEscape(value, builder.RemainingSpan, out var written); + builder.Advance(written); return true; } - private static bool NeedsCsvQuoting(ReadOnlySpan value) - { - if (value.Length == 0) return true; - if (char.IsWhiteSpace(value[0]) || char.IsWhiteSpace(value[^1])) return true; - foreach (var c in value) - { - if (c is ',' or '"' or '\n' or '\r') return true; - } - - return false; - } - // URL encoding and composition helpers private static bool IsUnreservedAscii(char c) @@ -982,61 +760,14 @@ public static ref ZaSpanStringBuilder AppendUrlEncoded(ref this ZaSpanStringBuil public static bool TryAppendUrlEncoded(ref this ZaSpanStringBuilder builder, ReadOnlySpan value) { - var needsEncoding = false; - for (int i = 0; i < value.Length; i++) - { - if (!IsUnreservedAscii(value[i])) - { - needsEncoding = true; - break; - } - } - - if (!needsEncoding) - { - return builder.TryAppend(value); - } - - var required = GetUrlEncodedLengthReplacingInvalid(value); + var required = UrlEscapeStrategy.GetEscapedLength(value); if (required > builder.RemainingSpan.Length) { return false; } - var dest = builder.RemainingSpan; - var w = 0; - for (var i = 0; i < value.Length; i++) - { - var c = value[i]; - if (c <= 0x7F) - { - if (IsUnreservedAscii(c)) - { - dest[w++] = c; - } - else - { - dest[w++] = '%'; - WriteHexByte((byte)c, dest.Slice(w, 2)); - w += 2; - } - } - else if (char.IsHighSurrogate(c) && i + 1 < value.Length && char.IsLowSurrogate(value[i + 1])) - { - var low = value[++i]; - var codePoint = 0x10000 + (c - 0xD800 << 10 | low - 0xDC00); - w += PercentEncodeUtf8FromCodePoint(codePoint, dest[w..]); - } - else - { - var codePoint = (int)c; - w += char.IsSurrogate(c) - ? WriteReplacementChar(dest[w..]) - : PercentEncodeUtf8FromCodePoint(codePoint, dest[w..]); - } - } - - builder.Advance(required); + UrlEscapeStrategy.TryEscape(value, builder.RemainingSpan, out var written); + builder.Advance(written); return true; } @@ -1055,145 +786,17 @@ public static ref ZaSpanStringBuilder AppendFormUrlEncoded(ref this ZaSpanString public static bool TryAppendFormUrlEncoded(ref this ZaSpanStringBuilder builder, ReadOnlySpan value) { - var needsEncoding = false; - for (int i = 0; i < value.Length; i++) - { - if (value[i] == ' ' || !IsUnreservedAscii(value[i])) - { - needsEncoding = true; - break; - } - } - - if (!needsEncoding) - { - return builder.TryAppend(value); - } - - var required = GetFormUrlEncodedLengthReplacingInvalid(value); + var required = FormUrlEscapeStrategy.GetEscapedLength(value); if (required > builder.RemainingSpan.Length) { return false; } - var dest = builder.RemainingSpan; - var w = 0; - for (var i = 0; i < value.Length; i++) - { - var c = value[i]; - if (c == ' ') - { - dest[w++] = '+'; - } - else if (c <= 0x7F) - { - if (IsUnreservedAscii(c)) - { - dest[w++] = c; - } - else - { - dest[w++] = '%'; - WriteHexByte((byte)c, dest.Slice(w, 2)); - w += 2; - } - } - else if (char.IsHighSurrogate(c) && i + 1 < value.Length && char.IsLowSurrogate(value[i + 1])) - { - var low = value[++i]; - var codePoint = 0x10000 + (c - 0xD800 << 10 | low - 0xDC00); - w += PercentEncodeUtf8FromCodePoint(codePoint, dest[w..]); - } - else - { - w += WriteReplacementChar(dest[w..]); - } - } - - builder.Advance(required); + FormUrlEscapeStrategy.TryEscape(value, builder.RemainingSpan, out var written); + builder.Advance(written); return true; } - private static int GetFormUrlEncodedLength(ReadOnlySpan value) - { - var length = 0; - for (var i = 0; i < value.Length; i++) - { - var c = value[i]; - if (c == ' ') - { - length += 1; - } - else if (c <= 0x7F) - { - length += IsUnreservedAscii(c) ? 1 : 3; - } - else if (char.IsHighSurrogate(c) && i + 1 < value.Length && char.IsLowSurrogate(value[i + 1])) - { - length += 4 * 3; - i++; - } - else - { - length += c <= 0x7FF ? 2 * 3 : 3 * 3; - } - } - - return length; - } - - private static int GetFormUrlEncodedLengthReplacingInvalid(ReadOnlySpan value) - { - var length = 0; - for (var i = 0; i < value.Length; i++) - { - var c = value[i]; - if (c == ' ') - { - length += 1; - } - else if (c <= 0x7F) - { - length += IsUnreservedAscii(c) ? 1 : 3; - } - else if (char.IsHighSurrogate(c) && i + 1 < value.Length && char.IsLowSurrogate(value[i + 1])) - { - length += 4 * 3; - i++; - } - else - { - length += 9; - } - } - - return length; - } - - private static int GetUrlEncodedLength(ReadOnlySpan value) - { - var length = 0; - for (var i = 0; i < value.Length; i++) - { - var c = value[i]; - if (c <= 0x7F) - { - length += IsUnreservedAscii(c) ? 1 : 3; - } - else if (char.IsHighSurrogate(c) && i + 1 < value.Length && char.IsLowSurrogate(value[i + 1])) - { - length += 4 * 3; - i++; - } - else - { - length += c <= 0x7FF ? 2 * 3 : 3 * 3; - } - } - - return length; - } - private static int GetUrlEncodedLengthReplacingInvalid(ReadOnlySpan value) { var length = 0; diff --git a/src/ZaString/ZaString.csproj b/src/ZaString/ZaString.csproj index 295777cc..35d55e72 100644 --- a/src/ZaString/ZaString.csproj +++ b/src/ZaString/ZaString.csproj @@ -1,6 +1,6 @@  - net8.0;net9.0;net10.0 + net10.0 v + 0.3 preview.0 diff --git a/tests/ZaString.Benchmarks/ZaString.Benchmarks.csproj b/tests/ZaString.Benchmarks/ZaString.Benchmarks.csproj index 157d95f0..2e04ec9a 100644 --- a/tests/ZaString.Benchmarks/ZaString.Benchmarks.csproj +++ b/tests/ZaString.Benchmarks/ZaString.Benchmarks.csproj @@ -1,6 +1,6 @@ - net8.0;net9.0;net10.0 + net10.0 Exe @@ -19,4 +19,4 @@ - \ No newline at end of file + diff --git a/tests/ZaString.Tests/CsvEscapeStrategyTests.cs b/tests/ZaString.Tests/CsvEscapeStrategyTests.cs new file mode 100644 index 00000000..c06dd2da --- /dev/null +++ b/tests/ZaString.Tests/CsvEscapeStrategyTests.cs @@ -0,0 +1,39 @@ +using ZaString.Escaping; + +namespace ZaString.Tests; + +public class CsvEscapeStrategyTests +{ + [Theory] + [InlineData("", "\"\"")] + [InlineData("plain", "plain")] + [InlineData(" leading", "\" leading\"")] + [InlineData("trailing ", "\"trailing \"")] + [InlineData("a,b", "\"a,b\"")] + [InlineData("a\"b", "\"a\"\"b\"")] + [InlineData("a\nb", "\"a\nb\"")] + [InlineData("a\rb", "\"a\rb\"")] + public void TryEscape_WritesCsvEscapedOutputAndWrittenCount(string input, string expected) + { + Span destination = stackalloc char[32]; + + var result = CsvEscapeStrategy.TryEscape(input, destination, out var written); + + Assert.True(result); + Assert.Equal(expected, destination[..written].ToString()); + Assert.Equal(CsvEscapeStrategy.GetEscapedLength(input), written); + } + + [Fact] + public void TryEscape_WithInsufficientDestination_ReturnsFalseWithoutWrittenChars() + { + Span destination = stackalloc char[4]; + destination.Fill('x'); + + var result = CsvEscapeStrategy.TryEscape("a,b", destination, out var written); + + Assert.False(result); + Assert.Equal(0, written); + Assert.Equal("xxxx", destination.ToString()); + } +} diff --git a/tests/ZaString.Tests/EscapeStrategyParityTests.cs b/tests/ZaString.Tests/EscapeStrategyParityTests.cs new file mode 100644 index 00000000..b4504a03 --- /dev/null +++ b/tests/ZaString.Tests/EscapeStrategyParityTests.cs @@ -0,0 +1,149 @@ +using ZaString.Core; +using ZaString.Escaping; +using ZaString.Extensions; + +namespace ZaString.Tests; + +public class EscapeStrategyParityTests +{ + public static TheoryData RepresentativeCases() + { + var urlInput = "a b/!\u20AC\ud83d\ude00" + new string('\uD800', 1); + return new TheoryData + { + { EscapeKind.Json, "quote: \" slash: \\ newline: \n separator: \u2028" }, + { EscapeKind.Html, "Tom & 'Jerry'" }, + { EscapeKind.Csv, " value, \"quoted\"\n" }, + { EscapeKind.Url, urlInput }, + { EscapeKind.FormUrl, urlInput } + }; + } + + [Theory] + [MemberData(nameof(RepresentativeCases))] + public void PooledEscaping_MatchesStackOwnedEscaping(EscapeKind kind, string input) + { + Span stackBuffer = stackalloc char[512]; + var stackBuilder = ZaSpanStringBuilder.Create(stackBuffer); + AppendStackOwned(ref stackBuilder, kind, input); + + using var pooledBuilder = ZaPooledStringBuilder.Rent(16); + AppendPooled(pooledBuilder, kind, input); + + Assert.Equal(stackBuilder.AsSpan().ToString(), pooledBuilder.ToString()); + } + + [Theory] + [MemberData(nameof(RepresentativeCases))] + public void EscapeStrategy_TryEscape_DoesNotAllocate(EscapeKind kind, string input) + { + Span destination = stackalloc char[512]; + + var before = GC.GetAllocatedBytesForCurrentThread(); + var result = TryEscapeStrategy(kind, input, destination, out var written); + var after = GC.GetAllocatedBytesForCurrentThread(); + + Assert.True(result); + Assert.True(written > 0); + Assert.Equal(before, after); + } + + [Theory] + [MemberData(nameof(RepresentativeCases))] + public void StackOwnedEscaping_DoesNotAllocateWithSufficientCapacity(EscapeKind kind, string input) + { + Span stackBuffer = stackalloc char[512]; + var stackBuilder = ZaSpanStringBuilder.Create(stackBuffer); + + var before = GC.GetAllocatedBytesForCurrentThread(); + AppendStackOwned(ref stackBuilder, kind, input); + var after = GC.GetAllocatedBytesForCurrentThread(); + + Assert.True(stackBuilder.Length > 0); + Assert.Equal(before, after); + } + + [Theory] + [MemberData(nameof(RepresentativeCases))] + public void PooledEscaping_DoesNotAllocateWithSufficientCapacity(EscapeKind kind, string input) + { + using var pooledBuilder = ZaPooledStringBuilder.Rent(512); + + var before = GC.GetAllocatedBytesForCurrentThread(); + AppendPooled(pooledBuilder, kind, input); + var after = GC.GetAllocatedBytesForCurrentThread(); + + Assert.True(pooledBuilder.Length > 0); + Assert.Equal(before, after); + } + + private static void AppendStackOwned(ref ZaSpanStringBuilder builder, EscapeKind kind, ReadOnlySpan input) + { + switch (kind) + { + case EscapeKind.Json: + builder.AppendJsonEscaped(input); + break; + case EscapeKind.Html: + builder.AppendHtmlEscaped(input); + break; + case EscapeKind.Csv: + builder.AppendCsvEscaped(input); + break; + case EscapeKind.Url: + builder.AppendUrlEncoded(input); + break; + case EscapeKind.FormUrl: + builder.AppendFormUrlEncoded(input); + break; + default: + throw new ArgumentOutOfRangeException(nameof(kind)); + } + } + + private static void AppendPooled(ZaPooledStringBuilder builder, EscapeKind kind, ReadOnlySpan input) + { + switch (kind) + { + case EscapeKind.Json: + builder.AppendJsonEscaped(input); + break; + case EscapeKind.Html: + builder.AppendHtmlEscaped(input); + break; + case EscapeKind.Csv: + builder.AppendCsvEscaped(input); + break; + case EscapeKind.Url: + builder.AppendUrlEncoded(input); + break; + case EscapeKind.FormUrl: + builder.AppendFormUrlEncoded(input); + break; + default: + throw new ArgumentOutOfRangeException(nameof(kind)); + } + } + + private static bool TryEscapeStrategy(EscapeKind kind, ReadOnlySpan input, Span destination, out int written) + { + return kind switch + { + EscapeKind.Json => JsonEscapeStrategy.TryEscape(input, destination, out written), + EscapeKind.Html => HtmlEscapeStrategy.TryEscape(input, destination, out written), + EscapeKind.Csv => CsvEscapeStrategy.TryEscape(input, destination, out written), + EscapeKind.Url => UrlEscapeStrategy.TryEscape(input, destination, out written), + EscapeKind.FormUrl => FormUrlEscapeStrategy.TryEscape(input, destination, out written), + _ => throw new ArgumentOutOfRangeException(nameof(kind)) + }; + } +} + +public enum EscapeKind +{ + Json, + Html, + Csv, + Url, + FormUrl +} diff --git a/tests/ZaString.Tests/FormUrlEscapeStrategyTests.cs b/tests/ZaString.Tests/FormUrlEscapeStrategyTests.cs new file mode 100644 index 00000000..e55aee09 --- /dev/null +++ b/tests/ZaString.Tests/FormUrlEscapeStrategyTests.cs @@ -0,0 +1,48 @@ +using ZaString.Escaping; + +namespace ZaString.Tests; + +public class FormUrlEscapeStrategyTests +{ + [Theory] + [InlineData("abc-_.~123", "abc-_.~123")] + [InlineData("a b/!", "a+b%2F%21")] + [InlineData("€", "%EF%BF%BD")] + [InlineData("😀", "%F0%9F%98%80")] + public void TryEscape_WritesFormUrlEncodedOutputAndWrittenCount(string input, string expected) + { + Span destination = stackalloc char[64]; + + var result = FormUrlEscapeStrategy.TryEscape(input, destination, out var written); + + Assert.True(result); + Assert.Equal(expected, destination[..written].ToString()); + Assert.Equal(FormUrlEscapeStrategy.GetEscapedLength(input), written); + } + + [Fact] + public void TryEscape_WithLoneHighSurrogate_UsesReplacementCharacter() + { + Span destination = stackalloc char[16]; + var input = new string('\uD800', 1); + + var result = FormUrlEscapeStrategy.TryEscape(input, destination, out var written); + + Assert.True(result); + Assert.Equal("%EF%BF%BD", destination[..written].ToString()); + Assert.Equal(FormUrlEscapeStrategy.GetEscapedLength(input), written); + } + + [Fact] + public void TryEscape_WithInsufficientDestination_ReturnsFalseWithoutWrittenChars() + { + Span destination = stackalloc char[4]; + destination.Fill('x'); + + var result = FormUrlEscapeStrategy.TryEscape("a b/", destination, out var written); + + Assert.False(result); + Assert.Equal(0, written); + Assert.Equal("xxxx", destination.ToString()); + } +} diff --git a/tests/ZaString.Tests/HtmlEscapeStrategyTests.cs b/tests/ZaString.Tests/HtmlEscapeStrategyTests.cs new file mode 100644 index 00000000..5141d94b --- /dev/null +++ b/tests/ZaString.Tests/HtmlEscapeStrategyTests.cs @@ -0,0 +1,31 @@ +using ZaString.Escaping; + +namespace ZaString.Tests; + +public class HtmlEscapeStrategyTests +{ + [Fact] + public void TryEscape_WritesHtmlEscapedOutputAndWrittenCount() + { + Span destination = stackalloc char[80]; + + var result = HtmlEscapeStrategy.TryEscape("", destination, out var written); + + Assert.True(result); + Assert.Equal("<tag attr="'&'>", destination[..written].ToString()); + Assert.Equal(HtmlEscapeStrategy.GetEscapedLength(""), written); + } + + [Fact] + public void TryEscape_WithInsufficientDestination_ReturnsFalseWithoutWrittenChars() + { + Span destination = stackalloc char[4]; + destination.Fill('x'); + + var result = HtmlEscapeStrategy.TryEscape("<>", destination, out var written); + + Assert.False(result); + Assert.Equal(0, written); + Assert.Equal("xxxx", destination.ToString()); + } +} diff --git a/tests/ZaString.Tests/JsonEscapeStrategyTests.cs b/tests/ZaString.Tests/JsonEscapeStrategyTests.cs new file mode 100644 index 00000000..dd630e11 --- /dev/null +++ b/tests/ZaString.Tests/JsonEscapeStrategyTests.cs @@ -0,0 +1,31 @@ +using ZaString.Escaping; + +namespace ZaString.Tests; + +public class JsonEscapeStrategyTests +{ + [Fact] + public void TryEscape_WritesJsonEscapedOutputAndWrittenCount() + { + Span destination = stackalloc char[64]; + + var result = JsonEscapeStrategy.TryEscape("\"A\\\n\u0001\u2028\u2029", destination, out var written); + + Assert.True(result); + Assert.Equal("\\\"A\\\\\\n\\u0001\\u2028\\u2029", destination[..written].ToString()); + Assert.Equal(JsonEscapeStrategy.GetEscapedLength("\"A\\\n\u0001\u2028\u2029"), written); + } + + [Fact] + public void TryEscape_WithInsufficientDestination_ReturnsFalseWithoutWrittenChars() + { + Span destination = stackalloc char[3]; + destination.Fill('x'); + + var result = JsonEscapeStrategy.TryEscape("\"test\"", destination, out var written); + + Assert.False(result); + Assert.Equal(0, written); + Assert.Equal("xxx", destination.ToString()); + } +} diff --git a/tests/ZaString.Tests/UrlEscapeStrategyTests.cs b/tests/ZaString.Tests/UrlEscapeStrategyTests.cs new file mode 100644 index 00000000..f4302a3e --- /dev/null +++ b/tests/ZaString.Tests/UrlEscapeStrategyTests.cs @@ -0,0 +1,48 @@ +using ZaString.Escaping; + +namespace ZaString.Tests; + +public class UrlEscapeStrategyTests +{ + [Theory] + [InlineData("abc-_.~123", "abc-_.~123")] + [InlineData("a b/!", "a%20b%2F%21")] + [InlineData("€", "%E2%82%AC")] + [InlineData("😀", "%F0%9F%98%80")] + public void TryEscape_WritesUrlEncodedOutputAndWrittenCount(string input, string expected) + { + Span destination = stackalloc char[64]; + + var result = UrlEscapeStrategy.TryEscape(input, destination, out var written); + + Assert.True(result); + Assert.Equal(expected, destination[..written].ToString()); + Assert.Equal(UrlEscapeStrategy.GetEscapedLength(input), written); + } + + [Fact] + public void TryEscape_WithLoneHighSurrogate_UsesReplacementCharacter() + { + Span destination = stackalloc char[16]; + var input = new string('\uD800', 1); + + var result = UrlEscapeStrategy.TryEscape(input, destination, out var written); + + Assert.True(result); + Assert.Equal("%EF%BF%BD", destination[..written].ToString()); + Assert.Equal(UrlEscapeStrategy.GetEscapedLength(input), written); + } + + [Fact] + public void TryEscape_WithInsufficientDestination_ReturnsFalseWithoutWrittenChars() + { + Span destination = stackalloc char[4]; + destination.Fill('x'); + + var result = UrlEscapeStrategy.TryEscape("a b", destination, out var written); + + Assert.False(result); + Assert.Equal(0, written); + Assert.Equal("xxxx", destination.ToString()); + } +} diff --git a/tests/ZaString.Tests/ZaPooledStringBuilderTests.cs b/tests/ZaString.Tests/ZaPooledStringBuilderTests.cs index bab21e1e..a84cc49d 100644 --- a/tests/ZaString.Tests/ZaPooledStringBuilderTests.cs +++ b/tests/ZaString.Tests/ZaPooledStringBuilderTests.cs @@ -1,6 +1,7 @@ using System.Buffers; using System.Globalization; using ZaString.Core; +using ZaString.Extensions; namespace ZaString.Tests; @@ -134,6 +135,131 @@ public void Append_ReadOnlySpan_AppendsCorrectly() Assert.Equal(5, builder.Length); } + [Fact] + public void AppendJsonEscaped_MatchesStackOwnedBuilder() + { + const string input = "quote: \" slash: \\ newline: \n separator: \u2028"; + Span stackBuffer = stackalloc char[128]; + var stackBuilder = ZaSpanStringBuilder.Create(stackBuffer); + stackBuilder.AppendJsonEscaped(input); + + using var pooledBuilder = ZaPooledStringBuilder.Rent(4); + pooledBuilder.AppendJsonEscaped(input.AsSpan()); + + Assert.Equal(stackBuilder.AsSpan().ToString(), pooledBuilder.ToString()); + } + + [Fact] + public void AppendJsonEscaped_GrowsRentedBuffer() + { + using var builder = ZaPooledStringBuilder.Rent(1); + + builder.AppendJsonEscaped("\n\n\n".AsSpan()); + + Assert.Equal("\\n\\n\\n", builder.ToString()); + Assert.True(builder.Capacity >= 6); + } + + [Fact] + public void AppendHtmlEscaped_MatchesStackOwnedBuilder() + { + const string input = "Tom & 'Jerry'"; + Span stackBuffer = stackalloc char[128]; + var stackBuilder = ZaSpanStringBuilder.Create(stackBuffer); + stackBuilder.AppendHtmlEscaped(input); + + using var pooledBuilder = ZaPooledStringBuilder.Rent(4); + pooledBuilder.AppendHtmlEscaped(input.AsSpan()); + + Assert.Equal(stackBuilder.AsSpan().ToString(), pooledBuilder.ToString()); + } + + [Fact] + public void AppendHtmlEscaped_GrowsRentedBuffer() + { + using var builder = ZaPooledStringBuilder.Rent(1); + + builder.AppendHtmlEscaped("&&".AsSpan()); + + Assert.Equal("&&", builder.ToString()); + Assert.True(builder.Capacity >= 10); + } + + [Fact] + public void AppendCsvEscaped_MatchesStackOwnedBuilder() + { + const string input = " value, \"quoted\"\n"; + Span stackBuffer = stackalloc char[128]; + var stackBuilder = ZaSpanStringBuilder.Create(stackBuffer); + stackBuilder.AppendCsvEscaped(input); + + using var pooledBuilder = ZaPooledStringBuilder.Rent(4); + pooledBuilder.AppendCsvEscaped(input.AsSpan()); + + Assert.Equal(stackBuilder.AsSpan().ToString(), pooledBuilder.ToString()); + } + + [Fact] + public void AppendCsvEscaped_GrowsRentedBuffer() + { + using var builder = ZaPooledStringBuilder.Rent(1); + + builder.AppendCsvEscaped("\"\"".AsSpan()); + + Assert.Equal("\"\"\"\"\"\"", builder.ToString()); + Assert.True(builder.Capacity >= 6); + } + + [Fact] + public void AppendUrlEncoded_MatchesStackOwnedBuilder() + { + var input = "a b/!\u20AC\ud83d\ude00" + new string('\uD800', 1); + Span stackBuffer = stackalloc char[256]; + var stackBuilder = ZaSpanStringBuilder.Create(stackBuffer); + stackBuilder.AppendUrlEncoded(input); + + using var pooledBuilder = ZaPooledStringBuilder.Rent(4); + pooledBuilder.AppendUrlEncoded(input.AsSpan()); + + Assert.Equal(stackBuilder.AsSpan().ToString(), pooledBuilder.ToString()); + } + + [Fact] + public void AppendUrlEncoded_GrowsRentedBuffer() + { + using var builder = ZaPooledStringBuilder.Rent(1); + + builder.AppendUrlEncoded("//".AsSpan()); + + Assert.Equal("%2F%2F", builder.ToString()); + Assert.True(builder.Capacity >= 6); + } + + [Fact] + public void AppendFormUrlEncoded_MatchesStackOwnedBuilder() + { + var input = "a b/!\u20AC\ud83d\ude00" + new string('\uD800', 1); + Span stackBuffer = stackalloc char[256]; + var stackBuilder = ZaSpanStringBuilder.Create(stackBuffer); + stackBuilder.AppendFormUrlEncoded(input); + + using var pooledBuilder = ZaPooledStringBuilder.Rent(4); + pooledBuilder.AppendFormUrlEncoded(input.AsSpan()); + + Assert.Equal(stackBuilder.AsSpan().ToString(), pooledBuilder.ToString()); + } + + [Fact] + public void AppendFormUrlEncoded_GrowsRentedBuffer() + { + using var builder = ZaPooledStringBuilder.Rent(1); + + builder.AppendFormUrlEncoded(" //".AsSpan()); + + Assert.Equal("+%2F%2F", builder.ToString()); + Assert.True(builder.Capacity >= 7); + } + [Fact] public void Append_Char_AppendsCorrectly() { diff --git a/tests/ZaString.Tests/ZaString.Tests.csproj b/tests/ZaString.Tests/ZaString.Tests.csproj index 43c0ced6..23f1e638 100644 --- a/tests/ZaString.Tests/ZaString.Tests.csproj +++ b/tests/ZaString.Tests/ZaString.Tests.csproj @@ -1,7 +1,7 @@  - net8.0;net9.0;net10.0 + net10.0 enable enable false