From ba6dded7107a2c10c9d45b931b7146df2ee88bf1 Mon Sep 17 00:00:00 2001 From: Corentin GS Date: Sun, 7 Jun 2026 17:04:16 +0200 Subject: [PATCH 01/11] Extract JSON escape strategy --- src/ZaString/Escaping/JsonEscapeStrategy.cs | 136 ++++++++++++++++++ .../ZaSpanStringBuilderExtensions.cs | 129 +---------------- .../ZaString.Tests/JsonEscapeStrategyTests.cs | 31 ++++ 3 files changed, 171 insertions(+), 125 deletions(-) create mode 100644 src/ZaString/Escaping/JsonEscapeStrategy.cs create mode 100644 tests/ZaString.Tests/JsonEscapeStrategyTests.cs 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/Extensions/ZaSpanStringBuilderExtensions.cs b/src/ZaString/Extensions/ZaSpanStringBuilderExtensions.cs index 0d0697fb..2c540cb7 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"; 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()); + } +} From 6da606582143b768ad42f743558baf8b83e2c642 Mon Sep 17 00:00:00 2001 From: Corentin GS Date: Sun, 7 Jun 2026 17:06:05 +0200 Subject: [PATCH 02/11] Extract HTML escape strategy --- src/ZaString/Escaping/HtmlEscapeStrategy.cs | 88 +++++++++++++++++++ .../ZaSpanStringBuilderExtensions.cs | 72 +-------------- .../ZaString.Tests/HtmlEscapeStrategyTests.cs | 31 +++++++ 3 files changed, 122 insertions(+), 69 deletions(-) create mode 100644 src/ZaString/Escaping/HtmlEscapeStrategy.cs create mode 100644 tests/ZaString.Tests/HtmlEscapeStrategyTests.cs 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/Extensions/ZaSpanStringBuilderExtensions.cs b/src/ZaString/Extensions/ZaSpanStringBuilderExtensions.cs index 2c540cb7..5492ec4e 100644 --- a/src/ZaString/Extensions/ZaSpanStringBuilderExtensions.cs +++ b/src/ZaString/Extensions/ZaSpanStringBuilderExtensions.cs @@ -700,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)) 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()); + } +} From 20f64b06b40166fe45317c6190f297e1e7ec6f4e Mon Sep 17 00:00:00 2001 From: Corentin GS Date: Sun, 7 Jun 2026 17:07:15 +0200 Subject: [PATCH 03/11] Extract CSV escape strategy --- src/ZaString/Escaping/CsvEscapeStrategy.cs | 75 +++++++++++++++++++ .../ZaSpanStringBuilderExtensions.cs | 41 +--------- .../ZaString.Tests/CsvEscapeStrategyTests.cs | 39 ++++++++++ 3 files changed, 117 insertions(+), 38 deletions(-) create mode 100644 src/ZaString/Escaping/CsvEscapeStrategy.cs create mode 100644 tests/ZaString.Tests/CsvEscapeStrategyTests.cs 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/Extensions/ZaSpanStringBuilderExtensions.cs b/src/ZaString/Extensions/ZaSpanStringBuilderExtensions.cs index 5492ec4e..84fdf8fb 100644 --- a/src/ZaString/Extensions/ZaSpanStringBuilderExtensions.cs +++ b/src/ZaString/Extensions/ZaSpanStringBuilderExtensions.cs @@ -723,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) 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()); + } +} From 37c7a47aea430de69ca9c2a1ba1fd0c498971fbe Mon Sep 17 00:00:00 2001 From: Corentin GS Date: Sun, 7 Jun 2026 17:08:20 +0200 Subject: [PATCH 04/11] Extract URL escape strategy --- src/ZaString/Escaping/UrlEscapeStrategy.cs | 160 ++++++++++++++++++ .../ZaSpanStringBuilderExtensions.cs | 77 +-------- .../ZaString.Tests/UrlEscapeStrategyTests.cs | 48 ++++++ 3 files changed, 211 insertions(+), 74 deletions(-) create mode 100644 src/ZaString/Escaping/UrlEscapeStrategy.cs create mode 100644 tests/ZaString.Tests/UrlEscapeStrategyTests.cs 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 84fdf8fb..4aa1cfeb 100644 --- a/src/ZaString/Extensions/ZaSpanStringBuilderExtensions.cs +++ b/src/ZaString/Extensions/ZaSpanStringBuilderExtensions.cs @@ -760,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; } @@ -948,30 +901,6 @@ private static int GetFormUrlEncodedLengthReplacingInvalid(ReadOnlySpan va 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/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()); + } +} From b45133a08d152a794240d15f8121e0b02a92ea7b Mon Sep 17 00:00:00 2001 From: Corentin GS Date: Sun, 7 Jun 2026 17:11:18 +0200 Subject: [PATCH 05/11] Extract form URL escape strategy --- .../Escaping/FormUrlEscapeStrategy.cs | 100 ++++++++++++++++ .../ZaSpanStringBuilderExtensions.cs | 110 +----------------- .../FormUrlEscapeStrategyTests.cs | 48 ++++++++ 3 files changed, 151 insertions(+), 107 deletions(-) create mode 100644 src/ZaString/Escaping/FormUrlEscapeStrategy.cs create mode 100644 tests/ZaString.Tests/FormUrlEscapeStrategyTests.cs 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/Extensions/ZaSpanStringBuilderExtensions.cs b/src/ZaString/Extensions/ZaSpanStringBuilderExtensions.cs index 4aa1cfeb..23835925 100644 --- a/src/ZaString/Extensions/ZaSpanStringBuilderExtensions.cs +++ b/src/ZaString/Extensions/ZaSpanStringBuilderExtensions.cs @@ -786,121 +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 GetUrlEncodedLengthReplacingInvalid(ReadOnlySpan value) { var length = 0; 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()); + } +} From 666f3057a1dd7611230159a23cb9af182780b5ac Mon Sep 17 00:00:00 2001 From: Corentin GS Date: Sun, 7 Jun 2026 17:13:20 +0200 Subject: [PATCH 06/11] Add pooled builder JSON escaping --- src/ZaString/Core/ZaPooledStringBuilder.cs | 35 +++++++++++++++++++ .../ZaPooledStringBuilderTests.cs | 26 ++++++++++++++ 2 files changed, 61 insertions(+) diff --git a/src/ZaString/Core/ZaPooledStringBuilder.cs b/src/ZaString/Core/ZaPooledStringBuilder.cs index c4cbf393..8ad25e4f 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,15 @@ 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 Append(T value, ReadOnlySpan format = default, IFormatProvider? provider = null) where T : ISpanFormattable { ThrowIfDisposed(); diff --git a/tests/ZaString.Tests/ZaPooledStringBuilderTests.cs b/tests/ZaString.Tests/ZaPooledStringBuilderTests.cs index bab21e1e..a9f3615e 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,31 @@ 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 Append_Char_AppendsCorrectly() { From 020a127b3a93986a2d8fa828f6df9e44b3da9da7 Mon Sep 17 00:00:00 2001 From: Corentin GS Date: Sun, 7 Jun 2026 17:16:02 +0200 Subject: [PATCH 07/11] Add pooled builder HTML and CSV escaping --- src/ZaString/Core/ZaPooledStringBuilder.cs | 18 +++++++ .../ZaPooledStringBuilderTests.cs | 50 +++++++++++++++++++ 2 files changed, 68 insertions(+) diff --git a/src/ZaString/Core/ZaPooledStringBuilder.cs b/src/ZaString/Core/ZaPooledStringBuilder.cs index 8ad25e4f..78b22780 100644 --- a/src/ZaString/Core/ZaPooledStringBuilder.cs +++ b/src/ZaString/Core/ZaPooledStringBuilder.cs @@ -268,6 +268,24 @@ public ZaPooledStringBuilder AppendJsonEscaped(ReadOnlySpan value) 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 Append(T value, ReadOnlySpan format = default, IFormatProvider? provider = null) where T : ISpanFormattable { ThrowIfDisposed(); diff --git a/tests/ZaString.Tests/ZaPooledStringBuilderTests.cs b/tests/ZaString.Tests/ZaPooledStringBuilderTests.cs index a9f3615e..6b6bd4e9 100644 --- a/tests/ZaString.Tests/ZaPooledStringBuilderTests.cs +++ b/tests/ZaString.Tests/ZaPooledStringBuilderTests.cs @@ -160,6 +160,56 @@ public void AppendJsonEscaped_GrowsRentedBuffer() 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 Append_Char_AppendsCorrectly() { From eb88a88ca2ac0ac960bdabb916a2eecf70c2b22e Mon Sep 17 00:00:00 2001 From: Corentin GS Date: Sun, 7 Jun 2026 17:17:05 +0200 Subject: [PATCH 08/11] Add pooled builder URL and form URL escaping --- src/ZaString/Core/ZaPooledStringBuilder.cs | 18 +++++++ .../ZaPooledStringBuilderTests.cs | 50 +++++++++++++++++++ 2 files changed, 68 insertions(+) diff --git a/src/ZaString/Core/ZaPooledStringBuilder.cs b/src/ZaString/Core/ZaPooledStringBuilder.cs index 78b22780..6ea19d14 100644 --- a/src/ZaString/Core/ZaPooledStringBuilder.cs +++ b/src/ZaString/Core/ZaPooledStringBuilder.cs @@ -286,6 +286,24 @@ public ZaPooledStringBuilder AppendCsvEscaped(ReadOnlySpan value) 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/tests/ZaString.Tests/ZaPooledStringBuilderTests.cs b/tests/ZaString.Tests/ZaPooledStringBuilderTests.cs index 6b6bd4e9..a84cc49d 100644 --- a/tests/ZaString.Tests/ZaPooledStringBuilderTests.cs +++ b/tests/ZaString.Tests/ZaPooledStringBuilderTests.cs @@ -210,6 +210,56 @@ public void AppendCsvEscaped_GrowsRentedBuffer() 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() { From 500a7c22419677268ad9546cd4e38a635bbc62d0 Mon Sep 17 00:00:00 2001 From: Corentin GS Date: Sun, 7 Jun 2026 17:18:36 +0200 Subject: [PATCH 09/11] Verify escape strategy parity and allocation guardrails --- .../EscapeStrategyParityTests.cs | 149 ++++++++++++++++++ 1 file changed, 149 insertions(+) create mode 100644 tests/ZaString.Tests/EscapeStrategyParityTests.cs 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 +} From fb51237fd5787a69abc34bb3cdf7bb0629574d03 Mon Sep 17 00:00:00 2001 From: Corentin GS Date: Sun, 7 Jun 2026 17:28:23 +0200 Subject: [PATCH 10/11] Update projects to .NET 10 --- samples/ZaString.Demo/ZaString.Demo.csproj | 2 +- src/ZaString/ZaString.csproj | 4 ++-- tests/ZaString.Benchmarks/ZaString.Benchmarks.csproj | 4 ++-- tests/ZaString.Tests/ZaString.Tests.csproj | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) 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/ZaString.csproj b/src/ZaString/ZaString.csproj index 295777cc..36f21111 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