From 2997dfd2a054894a38843f453f4aec66430a05f7 Mon Sep 17 00:00:00 2001 From: Corentin Giaufer Saubert <43623834+CorentinGS@users.noreply.github.com> Date: Thu, 7 Aug 2025 22:08:13 +0200 Subject: [PATCH 01/13] feat: Enhance ZaSpanStringBuilder.Append method to support custom format providers and use InvariantCulture by default --- .../ZaSpanStringBuilderExtensions.cs | 23 ++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/src/ZaString/Extensions/ZaSpanStringBuilderExtensions.cs b/src/ZaString/Extensions/ZaSpanStringBuilderExtensions.cs index 2dac637..6ad7a0a 100644 --- a/src/ZaString/Extensions/ZaSpanStringBuilderExtensions.cs +++ b/src/ZaString/Extensions/ZaSpanStringBuilderExtensions.cs @@ -1,3 +1,4 @@ +using System.Globalization; using ZaString.Core; namespace ZaString.Extensions; @@ -85,10 +86,26 @@ public static ref ZaSpanStringBuilder Append(ref this ZaSpanStringBuilder builde /// Thrown if the value cannot be formatted correctly. public static ref ZaSpanStringBuilder Append(ref this ZaSpanStringBuilder builder, T value, ReadOnlySpan format = default) where T : ISpanFormattable { - if (!value.TryFormat(builder.RemainingSpan, out var charsWritten, format, null)) + return ref builder.Append(value, format, CultureInfo.InvariantCulture); + } + + /// + /// Appends a value of a type that implements using the specified format provider. + /// + /// The type of the value, which must implement ISpanFormattable. + /// The builder instance. + /// The value to format and append. + /// An optional format string for the value. + /// Format provider to use. If null, is used. + /// A reference to the builder to allow for method chaining. + /// Thrown if the buffer is too small to hold the formatted value. + /// Thrown if the value cannot be formatted correctly. + public static ref ZaSpanStringBuilder Append(ref this ZaSpanStringBuilder builder, T value, ReadOnlySpan format, IFormatProvider? provider) where T : ISpanFormattable + { + provider ??= CultureInfo.InvariantCulture; + + if (!value.TryFormat(builder.RemainingSpan, out var charsWritten, format, provider)) { - // This can mean two things: buffer is too small, or format is invalid. - // We throw for "out of range" as it's the most common and actionable reason. ThrowOutOfRangeException(); } From 8193ea16fbe3dd065c3ffbc912ff934f6ab4ad71 Mon Sep 17 00:00:00 2001 From: Corentin Giaufer Saubert <43623834+CorentinGS@users.noreply.github.com> Date: Thu, 7 Aug 2025 22:13:18 +0200 Subject: [PATCH 02/13] feat: Add TryAppend methods to ZaSpanStringBuilder for safe appending of spans, strings, characters, and formatted values --- .../ZaSpanStringBuilderExtensions.cs | 108 ++++++++++ .../ZaSpanStringBuilderTryAppendTests.cs | 185 ++++++++++++++++++ 2 files changed, 293 insertions(+) create mode 100644 tests/ZaString.Tests/ZaSpanStringBuilderTryAppendTests.cs diff --git a/src/ZaString/Extensions/ZaSpanStringBuilderExtensions.cs b/src/ZaString/Extensions/ZaSpanStringBuilderExtensions.cs index 6ad7a0a..038bb50 100644 --- a/src/ZaString/Extensions/ZaSpanStringBuilderExtensions.cs +++ b/src/ZaString/Extensions/ZaSpanStringBuilderExtensions.cs @@ -8,6 +8,114 @@ namespace ZaString.Extensions; /// public static class ZaSpanStringBuilderExtensions { + /// + /// Attempts to append a read-only span of characters to the builder without throwing. + /// + /// The builder instance. + /// The span of characters to append. + /// true if the value was appended; otherwise false if there was not enough capacity. + public static bool TryAppend(ref this ZaSpanStringBuilder builder, ReadOnlySpan value) + { + if (value.Length > builder.RemainingSpan.Length) + { + return false; + } + + value.CopyTo(builder.RemainingSpan); + builder.Advance(value.Length); + return true; + } + + /// + /// Attempts to append a string to the builder without throwing. + /// + /// The builder instance. + /// The string to append. If null, this is a no-op and returns true. + /// true if the value was appended; otherwise false if there was not enough capacity. + public static bool TryAppend(ref this ZaSpanStringBuilder builder, string? value) + { + return value is null || builder.TryAppend(value.AsSpan()); + } + + /// + /// Attempts to append a single character to the builder without throwing. + /// + /// The builder instance. + /// The character to append. + /// true if the value was appended; otherwise false if there was not enough capacity. + public static bool TryAppend(ref this ZaSpanStringBuilder builder, char value) + { + if (builder.RemainingSpan.Length < 1) + { + return false; + } + + builder.RemainingSpan[0] = value; + builder.Advance(1); + return true; + } + + /// + /// Attempts to append a value of a type that implements without throwing. + /// + /// The type of the value, which must implement ISpanFormattable. + /// The builder instance. + /// The value to format and append. + /// An optional format string for the value. + /// Format provider to use. If null, is used. + /// true if the value was formatted and appended; otherwise false if there was not enough capacity or formatting failed. + public static bool TryAppend(ref this ZaSpanStringBuilder builder, T value, ReadOnlySpan format = default, IFormatProvider? provider = null) where T : ISpanFormattable + { + provider ??= CultureInfo.InvariantCulture; + + if (!value.TryFormat(builder.RemainingSpan, out var charsWritten, format, provider)) + { + return false; + } + + builder.Advance(charsWritten); + return true; + } + + /// + /// Attempts to append the default line terminator to the builder without throwing. + /// + /// The builder instance. + /// true if the newline was appended; otherwise false if there was not enough capacity. + public static bool TryAppendLine(ref this ZaSpanStringBuilder builder) + { + var newline = Environment.NewLine.AsSpan(); + return builder.TryAppend(newline); + } + + /// + /// Attempts to append a string followed by the default line terminator to the builder without throwing. + /// The operation is atomic with respect to capacity: if there is not enough space for both, nothing is written. + /// + /// The builder instance. + /// The string to append. If null, only the newline is appended. + /// true if the string and newline were appended; otherwise false if there was not enough capacity. + public static bool TryAppendLine(ref this ZaSpanStringBuilder builder, string? value) + { + var valueLength = value?.Length ?? 0; + var newlineLength = Environment.NewLine.Length; + var required = valueLength + newlineLength; + + if (required > builder.RemainingSpan.Length) + { + return false; + } + + if (valueLength > 0) + { + value!.AsSpan().CopyTo(builder.RemainingSpan); + builder.Advance(valueLength); + } + + Environment.NewLine.AsSpan().CopyTo(builder.RemainingSpan); + builder.Advance(newlineLength); + return true; + } /// /// Appends a read-only span of characters to the builder. /// diff --git a/tests/ZaString.Tests/ZaSpanStringBuilderTryAppendTests.cs b/tests/ZaString.Tests/ZaSpanStringBuilderTryAppendTests.cs new file mode 100644 index 0000000..abaaab0 --- /dev/null +++ b/tests/ZaString.Tests/ZaSpanStringBuilderTryAppendTests.cs @@ -0,0 +1,185 @@ +using System.Globalization; +using ZaString.Core; +using ZaString.Extensions; + +namespace ZaString.Tests; + +public class ZaSpanStringBuilderTryAppendTests +{ + [Fact] + public void TryAppend_ReadOnlySpan_Succeeds() + { + Span buffer = stackalloc char[10]; + var builder = ZaSpanStringBuilder.Create(buffer); + + var ok = builder.TryAppend("Hello".AsSpan()); + + Assert.True(ok); + Assert.Equal(5, builder.Length); + Assert.Equal("Hello", builder.AsSpan()); + } + + [Fact] + public void TryAppend_ReadOnlySpan_InsufficientCapacity_ReturnsFalse_NoChange() + { + Span buffer = stackalloc char[3]; + var builder = ZaSpanStringBuilder.Create(buffer); + + var ok = builder.TryAppend("Hello".AsSpan()); + + Assert.False(ok); + Assert.Equal(0, builder.Length); + Assert.Equal("", builder.AsSpan()); + } + + [Fact] + public void TryAppend_String_Null_ReturnsTrue_NoChange() + { + Span buffer = stackalloc char[3]; + var builder = ZaSpanStringBuilder.Create(buffer); + + var ok = builder.TryAppend((string?)null); + + Assert.True(ok); + Assert.Equal(0, builder.Length); + Assert.Equal("", builder.AsSpan()); + } + + [Fact] + public void TryAppend_String_Succeeds() + { + Span buffer = stackalloc char[5]; + var builder = ZaSpanStringBuilder.Create(buffer); + + var ok = builder.TryAppend("Hello"); + + Assert.True(ok); + Assert.Equal("Hello", builder.AsSpan()); + } + + [Fact] + public void TryAppend_Char_Succeeds() + { + Span buffer = stackalloc char[1]; + var builder = ZaSpanStringBuilder.Create(buffer); + + var ok = builder.TryAppend('A'); + + Assert.True(ok); + Assert.Equal("A", builder.AsSpan()); + Assert.Equal(1, builder.Length); + } + + [Fact] + public void TryAppend_Char_Insufficient_ReturnsFalse_NoChange() + { + var builder = ZaSpanStringBuilder.Create(Span.Empty); + + var ok = builder.TryAppend('A'); + + Assert.False(ok); + Assert.Equal(0, builder.Length); + Assert.Equal("", builder.AsSpan()); + } + + [Fact] + public void TryAppend_ISpanFormattable_Double_DefaultProvider_UsesInvariant() + { + var originalCulture = CultureInfo.CurrentCulture; + try + { + // Set a culture with comma decimal separator to ensure provider default is invariant + CultureInfo.CurrentCulture = new CultureInfo("fr-FR"); + + Span buffer = stackalloc char[10]; + var builder = ZaSpanStringBuilder.Create(buffer); + + var ok = builder.TryAppend(1.5); + + Assert.True(ok); + Assert.Equal("1.5", builder.AsSpan()); + } + finally + { + CultureInfo.CurrentCulture = originalCulture; + } + } + + [Fact] + public void TryAppend_ISpanFormattable_Double_CustomProvider_RespectsProvider() + { + var fr = new CultureInfo("fr-FR"); + Span buffer = stackalloc char[10]; + var builder = ZaSpanStringBuilder.Create(buffer); + + var ok = builder.TryAppend(1.5, provider: fr); + + Assert.True(ok); + Assert.Equal("1,5", builder.AsSpan()); + } + + [Fact] + public void TryAppendLine_OnlyNewline_Succeeds_WhenCapacitySufficient() + { + var newlineLen = Environment.NewLine.Length; + Span buffer = stackalloc char[newlineLen]; + var builder = ZaSpanStringBuilder.Create(buffer); + + var ok = builder.TryAppendLine(); + + Assert.True(ok); + Assert.Equal(Environment.NewLine, builder.AsSpan()); + Assert.Equal(newlineLen, builder.Length); + } + + [Fact] + public void TryAppendLine_OnlyNewline_InsufficientCapacity_ReturnsFalse_NoChange() + { + var newlineLen = Environment.NewLine.Length; + var capacity = Math.Max(0, newlineLen - 1); + Span buffer = capacity == 0 ? Span.Empty : stackalloc char[capacity]; + var builder = ZaSpanStringBuilder.Create(buffer); + + var ok = builder.TryAppendLine(); + + Assert.False(ok); + Assert.Equal(0, builder.Length); + Assert.Equal("", builder.AsSpan()); + } + + [Fact] + public void TryAppendLine_String_AtomicFailure_NoPartialWrite() + { + var value = "Hi"; // length 2 + var newlineLen = Environment.NewLine.Length; + var required = value.Length + newlineLen; + var capacity = Math.Max(value.Length, required - 1); // ensure not enough for both, but possibly enough for value + + Span buffer = stackalloc char[capacity]; + var builder = ZaSpanStringBuilder.Create(buffer); + + var ok = builder.TryAppendLine(value); + + Assert.False(ok); + Assert.Equal(0, builder.Length); + Assert.Equal("", builder.AsSpan()); + } + + [Fact] + public void TryAppendLine_String_Succeeds_WhenCapacitySufficient() + { + var value = "Hello"; + var newlineLen = Environment.NewLine.Length; + var required = value.Length + newlineLen; + + Span buffer = stackalloc char[required]; + var builder = ZaSpanStringBuilder.Create(buffer); + + var ok = builder.TryAppendLine(value); + + Assert.True(ok); + Assert.Equal(value + Environment.NewLine, builder.AsSpan()); + Assert.Equal(required, builder.Length); + } +} + From 67cd48b2f5c999eaeabafec0864dce0514847a81 Mon Sep 17 00:00:00 2001 From: Corentin Giaufer Saubert <43623834+CorentinGS@users.noreply.github.com> Date: Thu, 7 Aug 2025 22:21:36 +0200 Subject: [PATCH 03/13] feat: Introduce AppendRepeat and AppendJoin methods to ZaSpanStringBuilder for enhanced string manipulation capabilities --- samples/ZaString.Demo/Program.cs | 59 ++++++++++++ .../ZaSpanStringBuilderExtensions.cs | 93 +++++++++++++++++++ .../ZaSpanStringBuilderAppendHelpersTests.cs | 82 ++++++++++++++++ 3 files changed, 234 insertions(+) create mode 100644 tests/ZaString.Tests/ZaSpanStringBuilderAppendHelpersTests.cs diff --git a/samples/ZaString.Demo/Program.cs b/samples/ZaString.Demo/Program.cs index 60ec05c..b4640b4 100644 --- a/samples/ZaString.Demo/Program.cs +++ b/samples/ZaString.Demo/Program.cs @@ -1,5 +1,6 @@ using System.Diagnostics; using System.Text; +using System.Globalization; using ZaString.Core; using ZaString.Extensions; @@ -37,9 +38,67 @@ public static void Main() CharacterModificationDemo(); Console.WriteLine(); + TryAppendDemo(); + Console.WriteLine(); + + AppendHelpersDemo(); + Console.WriteLine(); + Console.WriteLine("Demo complete!"); } + private static void TryAppendDemo() + { + Console.WriteLine("--- TryAppend ---"); + + // Small buffer: demonstrate non-throwing false return + Span small = stackalloc char[3]; + var builder = ZaSpanStringBuilder.Create(small); + var ok = builder.TryAppend("Hello"); + Console.WriteLine($"TryAppend(\"Hello\") ok: {ok}, Length: {builder.Length}"); + ok = builder.TryAppend('A'); + Console.WriteLine($"TryAppend('A') ok: {ok}, Text: '{builder.AsSpan()}'"); + + // Sufficient buffer: show newline and culture-safe formatting + Span buffer = stackalloc char[32]; + builder = ZaSpanStringBuilder.Create(buffer); + builder.TryAppend("Hi"); + builder.TryAppend(' '); + builder.TryAppendLine(); + builder.TryAppend(1.5); // uses InvariantCulture by default => "1.5" + Console.WriteLine($"With newline + double (invariant): '{builder.AsSpan()}'"); + + // Custom provider formatting via TryAppend + Span bufferFr = stackalloc char[16]; + var fr = new CultureInfo("fr-FR"); + builder = ZaSpanStringBuilder.Create(bufferFr); + builder.TryAppend(1.5, provider: fr); + Console.WriteLine($"Double with fr-FR: '{builder.AsSpan()}'"); + } + + private static void AppendHelpersDemo() + { + Console.WriteLine("--- Append Helpers (Repeat/Join) ---"); + + Span buffer = stackalloc char[128]; + var builder = ZaSpanStringBuilder.Create(buffer); + + // AppendRepeat and TryAppendRepeat + builder.AppendRepeat('-', 10).AppendLine(); + var repeatedOk = builder.TryAppendRepeat('*', 5); + builder.AppendLine(); + Console.WriteLine($"Repeat appended ok: {repeatedOk}"); + + // AppendJoin with strings (null treated as empty) + builder.AppendJoin(", ".AsSpan(), "a", null, "c").AppendLine(); + + // AppendJoin with ISpanFormattable values and culture + var values = new double[] { 1.5, 2.5, 3.5 }; + builder.AppendJoin("; ".AsSpan(), values, provider: new CultureInfo("fr-FR")); + + Console.WriteLine(builder.AsSpan().ToString()); + } + private static void BasicUsageDemo() { Console.WriteLine("--- Basic Usage ---"); diff --git a/src/ZaString/Extensions/ZaSpanStringBuilderExtensions.cs b/src/ZaString/Extensions/ZaSpanStringBuilderExtensions.cs index 038bb50..1432ec0 100644 --- a/src/ZaString/Extensions/ZaSpanStringBuilderExtensions.cs +++ b/src/ZaString/Extensions/ZaSpanStringBuilderExtensions.cs @@ -8,6 +8,99 @@ namespace ZaString.Extensions; /// public static class ZaSpanStringBuilderExtensions { + /// + /// Appends the specified character repeated times. + /// + /// Thrown if count is negative or buffer is too small. + public static ref ZaSpanStringBuilder AppendRepeat(ref this ZaSpanStringBuilder builder, char value, int count) + { + if (count < 0) + { + throw new ArgumentOutOfRangeException(nameof(count)); + } + + if (count == 0) + { + return ref builder; + } + + if (builder.RemainingSpan.Length < count) + { + ThrowOutOfRangeException(); + } + + builder.RemainingSpan[..count].Fill(value); + builder.Advance(count); + return ref builder; + } + + /// + /// Attempts to append the specified character repeated times without throwing. + /// + /// true if appended; otherwise false when capacity is insufficient. + public static bool TryAppendRepeat(ref this ZaSpanStringBuilder builder, char value, int count) + { + if (count < 0) + { + throw new ArgumentOutOfRangeException(nameof(count)); + } + + if (count == 0) + { + return true; + } + + if (builder.RemainingSpan.Length < count) + { + return false; + } + + builder.RemainingSpan[..count].Fill(value); + builder.Advance(count); + return true; + } + + /// + /// Appends the elements separated by . + /// Null elements are treated as empty strings. + /// + public static ref ZaSpanStringBuilder AppendJoin(ref this ZaSpanStringBuilder builder, ReadOnlySpan separator, params string?[] values) + { + for (var i = 0; i < values.Length; i++) + { + if (i > 0) + { + builder.Append(separator); + } + + var s = values[i]; + if (s is not null) + { + builder.Append(s); + } + } + + return ref builder; + } + + /// + /// Appends the elements of separated by . + /// + public static ref ZaSpanStringBuilder AppendJoin(ref this ZaSpanStringBuilder builder, ReadOnlySpan separator, ReadOnlySpan values, ReadOnlySpan format = default, IFormatProvider? provider = null) + where T : ISpanFormattable + { + for (var i = 0; i < values.Length; i++) + { + if (i > 0) + { + builder.Append(separator); + } + + builder.Append(values[i], format, provider); + } + + return ref builder; + } /// /// Attempts to append a read-only span of characters to the builder without throwing. /// diff --git a/tests/ZaString.Tests/ZaSpanStringBuilderAppendHelpersTests.cs b/tests/ZaString.Tests/ZaSpanStringBuilderAppendHelpersTests.cs new file mode 100644 index 0000000..b2fc737 --- /dev/null +++ b/tests/ZaString.Tests/ZaSpanStringBuilderAppendHelpersTests.cs @@ -0,0 +1,82 @@ +using System.Globalization; +using ZaString.Core; +using ZaString.Extensions; + +namespace ZaString.Tests; + +public class ZaSpanStringBuilderAppendHelpersTests +{ + [Fact] + public void AppendRepeat_AppendsCorrectly() + { + Span buffer = stackalloc char[5]; + var builder = ZaSpanStringBuilder.Create(buffer); + + builder.AppendRepeat('x', 5); + + Assert.Equal("xxxxx", builder.AsSpan()); + Assert.Equal(5, builder.Length); + } + + [Fact] + public void AppendRepeat_Zero_NoChange() + { + Span buffer = stackalloc char[3]; + var builder = ZaSpanStringBuilder.Create(buffer); + + builder.AppendRepeat('x', 0); + + Assert.Equal(0, builder.Length); + Assert.Equal("", builder.AsSpan()); + } + + [Fact] + public void TryAppendRepeat_Succeeds_WhenCapacitySufficient() + { + Span buffer = stackalloc char[4]; + var builder = ZaSpanStringBuilder.Create(buffer); + + var ok = builder.TryAppendRepeat('a', 4); + + Assert.True(ok); + Assert.Equal("aaaa", builder.AsSpan()); + } + + [Fact] + public void TryAppendRepeat_InsufficientCapacity_ReturnsFalse_NoChange() + { + Span buffer = stackalloc char[3]; + var builder = ZaSpanStringBuilder.Create(buffer); + + var ok = builder.TryAppendRepeat('a', 4); + + Assert.False(ok); + Assert.Equal(0, builder.Length); + Assert.Equal("", builder.AsSpan()); + } + + [Fact] + public void AppendJoin_Strings_Works() + { + Span buffer = stackalloc char[20]; + var builder = ZaSpanStringBuilder.Create(buffer); + + builder.AppendJoin(", ".AsSpan(), "a", null, "c"); + + Assert.Equal("a, , c", builder.AsSpan()); + } + + [Fact] + public void AppendJoin_ISpanFormattable_Works_WithProvider() + { + var fr = new CultureInfo("fr-FR"); + Span buffer = stackalloc char[20]; + var builder = ZaSpanStringBuilder.Create(buffer); + + var values = new[] { 1.5, 2.5 }; + builder.AppendJoin("; ".AsSpan(), values, provider: fr); + + Assert.Equal("1,5; 2,5", builder.AsSpan()); + } +} + From 0ddb7474c16ceb5306aaecc1d1d8903be732fd77 Mon Sep 17 00:00:00 2001 From: Corentin Giaufer Saubert <43623834+CorentinGS@users.noreply.github.com> Date: Thu, 7 Aug 2025 22:27:47 +0200 Subject: [PATCH 04/13] feat: Add SetLength and RemoveLast methods to ZaSpanStringBuilder for enhanced string manipulation, along with extension methods for safe usage --- src/ZaString/Core/ZaSpanString.cs | 32 ++++++- .../ZaSpanStringBuilderExtensions.cs | 90 +++++++++++++++++++ ...ZaSpanStringBuilderMutationHelpersTests.cs | 59 ++++++++++++ 3 files changed, 180 insertions(+), 1 deletion(-) create mode 100644 tests/ZaString.Tests/ZaSpanStringBuilderMutationHelpersTests.cs diff --git a/src/ZaString/Core/ZaSpanString.cs b/src/ZaString/Core/ZaSpanString.cs index c3ece7d..0c2c9da 100644 --- a/src/ZaString/Core/ZaSpanString.cs +++ b/src/ZaString/Core/ZaSpanString.cs @@ -1,4 +1,6 @@ -namespace ZaString.Core; +using System.Diagnostics; + +namespace ZaString.Core; /// /// A zero-allocation string builder that writes directly to a provided Span @@ -87,6 +89,8 @@ public static ZaSpanStringBuilder Create(Span buffer) /// The number of characters written. public void Advance(int count) { + Debug.Assert(count >= 0, "Advance count must be non-negative."); + Debug.Assert(Length + count <= Capacity, "Advance would exceed capacity."); Length += count; } @@ -99,6 +103,32 @@ public void Clear() Length = 0; } + /// + /// Reduces the current length to the specified value. Only truncation is allowed. + /// + /// The new length, which must be between 0 and the current Length. + /// Thrown if newLength is negative or greater than the current Length. + public void SetLength(int newLength) + { + if ((uint)newLength > (uint)Length) + throw new ArgumentOutOfRangeException(nameof(newLength)); + + Length = newLength; + } + + /// + /// Removes the last characters from the written span. + /// + /// Number of characters to remove. Must be between 0 and current Length. + /// Thrown if count is negative or greater than current Length. + public void RemoveLast(int count) + { + if ((uint)count > (uint)Length) + throw new ArgumentOutOfRangeException(nameof(count)); + + Length -= count; + } + /// /// Returns the built string as a . /// diff --git a/src/ZaString/Extensions/ZaSpanStringBuilderExtensions.cs b/src/ZaString/Extensions/ZaSpanStringBuilderExtensions.cs index 1432ec0..917c7c3 100644 --- a/src/ZaString/Extensions/ZaSpanStringBuilderExtensions.cs +++ b/src/ZaString/Extensions/ZaSpanStringBuilderExtensions.cs @@ -8,6 +8,96 @@ namespace ZaString.Extensions; /// public static class ZaSpanStringBuilderExtensions { + /// + /// Attempts to reserve a writable span of the specified size without throwing. + /// On success, caller must write up to characters and then call with the actual number written. + /// + /// The builder. + /// Requested size to reserve. + /// The span the caller can write into. + /// true if reserved; false if capacity is insufficient. + public static bool TryGetAppendSpan(ref this ZaSpanStringBuilder builder, int size, out Span writeSpan) + { + if (size < 0) + { + throw new ArgumentOutOfRangeException(nameof(size)); + } + + if (size == 0) + { + writeSpan = Span.Empty; + return true; + } + + if (builder.RemainingSpan.Length < size) + { + writeSpan = Span.Empty; + return false; + } + + writeSpan = builder.RemainingSpan[..size]; + return true; + } + + /// + /// Reserves a writable span of the specified size or throws if the capacity is insufficient. + /// On success, caller must write up to characters and then call with the actual number written. + /// + public static ref ZaSpanStringBuilder GetAppendSpan(ref this ZaSpanStringBuilder builder, int size, out Span writeSpan) + { + if (!TryGetAppendSpan(ref builder, size, out writeSpan)) + { + ThrowOutOfRangeException(); + } + + return ref builder; + } + + /// + /// Removes the last characters from the written span. + /// + public static ref ZaSpanStringBuilder RemoveLast(ref this ZaSpanStringBuilder builder, int count) + { + if (count < 0 || count > builder.Length) + { + throw new ArgumentOutOfRangeException(nameof(count)); + } + + if (count > 0) + { + builder.RemoveLast(count); + } + + return ref builder; + } + + /// + /// Sets the current length to . Must be between 0 and Capacity. + /// If is less than the current Length, the content is logically truncated. + /// + public static ref ZaSpanStringBuilder SetLength(ref this ZaSpanStringBuilder builder, int newLength) + { + if (newLength < 0 || newLength > builder.Length) + { + throw new ArgumentOutOfRangeException(nameof(newLength)); + } + + builder.SetLength(newLength); + return ref builder; + } + + /// + /// Ensures the written span ends with the specified character; appends it if needed. + /// + public static ref ZaSpanStringBuilder EnsureEndsWith(ref this ZaSpanStringBuilder builder, char value) + { + if (builder.Length == 0 || builder[builder.Length - 1] != value) + { + builder.Append(value); + } + + return ref builder; + } /// /// Appends the specified character repeated times. /// diff --git a/tests/ZaString.Tests/ZaSpanStringBuilderMutationHelpersTests.cs b/tests/ZaString.Tests/ZaSpanStringBuilderMutationHelpersTests.cs new file mode 100644 index 0000000..51a5b0d --- /dev/null +++ b/tests/ZaString.Tests/ZaSpanStringBuilderMutationHelpersTests.cs @@ -0,0 +1,59 @@ +using ZaString.Core; +using ZaString.Extensions; + +namespace ZaString.Tests; + +public class ZaSpanStringBuilderMutationHelpersTests +{ + [Fact] + public void SetLength_Truncates() + { + Span buffer = stackalloc char[16]; + var builder = ZaSpanStringBuilder.Create(buffer); + builder.Append("abcdef"); + + builder.SetLength(3); + + Assert.Equal(3, builder.Length); + Assert.Equal("abc", builder.AsSpan()); + } + + [Fact] + public void RemoveLast_RemovesCorrectly() + { + Span buffer = stackalloc char[16]; + var builder = ZaSpanStringBuilder.Create(buffer); + builder.Append("abcdef"); + + builder.RemoveLast(2); + + Assert.Equal("abcd", builder.AsSpan()); + Assert.Equal(4, builder.Length); + } + + [Fact] + public void EnsureEndsWith_AppendsWhenMissing() + { + Span buffer = stackalloc char[10]; + var builder = ZaSpanStringBuilder.Create(buffer); + builder.Append("abc"); + + builder.EnsureEndsWith('x'); + + Assert.Equal("abcx", builder.AsSpan()); + Assert.Equal('x', builder[builder.Length - 1]); + } + + [Fact] + public void EnsureEndsWith_NoOpWhenAlreadyEnding() + { + Span buffer = stackalloc char[10]; + var builder = ZaSpanStringBuilder.Create(buffer); + builder.Append("abcx"); + + builder.EnsureEndsWith('x'); + + Assert.Equal("abcx", builder.AsSpan()); + } +} + From e7d0cb0ed2d55b03ec45bf913aac4a057ca99492 Mon Sep 17 00:00:00 2001 From: Corentin Giaufer Saubert <43623834+CorentinGS@users.noreply.github.com> Date: Thu, 7 Aug 2025 22:36:04 +0200 Subject: [PATCH 05/13] feat: Implement ZaInterpolatedStringHandler for enhanced string interpolation support in ZaSpanStringBuilder, including new extension methods for appending interpolated strings --- samples/ZaString.Demo/Program.cs | 22 +++++++ .../Extensions/ZaInterpolatedStringHandler.cs | 57 +++++++++++++++++++ .../ZaSpanStringBuilderExtensions.cs | 21 +++++++ .../ZaSpanStringBuilderInterpolationTests.cs | 47 +++++++++++++++ 4 files changed, 147 insertions(+) create mode 100644 src/ZaString/Extensions/ZaInterpolatedStringHandler.cs create mode 100644 tests/ZaString.Tests/ZaSpanStringBuilderInterpolationTests.cs diff --git a/samples/ZaString.Demo/Program.cs b/samples/ZaString.Demo/Program.cs index b4640b4..2a6499c 100644 --- a/samples/ZaString.Demo/Program.cs +++ b/samples/ZaString.Demo/Program.cs @@ -44,6 +44,9 @@ public static void Main() AppendHelpersDemo(); Console.WriteLine(); + InterpolationDemo(); + Console.WriteLine(); + Console.WriteLine("Demo complete!"); } @@ -99,6 +102,25 @@ private static void AppendHelpersDemo() Console.WriteLine(builder.AsSpan().ToString()); } + private static void InterpolationDemo() + { + Console.WriteLine("--- Interpolated String Handler ---"); + + Span buffer = stackalloc char[128]; + var builder = ZaSpanStringBuilder.Create(buffer); + + var name = "Alice"; + var age = 30; + var pi = Math.PI; + + builder.Append($"User: {name}, Age: {age}, Pi: {pi:F2}"); + Console.WriteLine(builder.AsSpan().ToString()); + + builder.Clear(); + builder.AppendLine($"Line: {42}"); + Console.WriteLine(builder.AsSpan().ToString()); + } + private static void BasicUsageDemo() { Console.WriteLine("--- Basic Usage ---"); diff --git a/src/ZaString/Extensions/ZaInterpolatedStringHandler.cs b/src/ZaString/Extensions/ZaInterpolatedStringHandler.cs new file mode 100644 index 0000000..2414729 --- /dev/null +++ b/src/ZaString/Extensions/ZaInterpolatedStringHandler.cs @@ -0,0 +1,57 @@ +using System.Globalization; +using System.Runtime.CompilerServices; +using ZaString.Core; + +namespace ZaString.Extensions; + +[InterpolatedStringHandler] +public ref struct ZaInterpolatedStringHandler +{ + private ZaSpanStringBuilder _builder; + private readonly IFormatProvider? _provider; + + public ZaInterpolatedStringHandler(int literalLength, int formattedCount, ref ZaSpanStringBuilder builder) + { + _builder = builder; + _provider = CultureInfo.InvariantCulture; + } + + public ZaInterpolatedStringHandler(int literalLength, int formattedCount, ref ZaSpanStringBuilder builder, IFormatProvider? provider) + { + _builder = builder; + _provider = provider ?? CultureInfo.InvariantCulture; + } + + public void AppendLiteral(string value) + { + _builder.Append(value); + } + + public void AppendFormatted(string? value) + { + _builder.Append(value); + } + + public void AppendFormatted(ReadOnlySpan value) + { + _builder.Append(value); + } + + public void AppendFormatted(char value) + { + _builder.Append(value); + } + + public void AppendFormatted(T value) where T : ISpanFormattable + { + _builder.Append(value, default, _provider); + } + + public void AppendFormatted(T value, string? format) where T : ISpanFormattable + { + _builder.Append(value, format, _provider); + } + + public ZaSpanStringBuilder GetResult() => _builder; +} + diff --git a/src/ZaString/Extensions/ZaSpanStringBuilderExtensions.cs b/src/ZaString/Extensions/ZaSpanStringBuilderExtensions.cs index 917c7c3..b389bb6 100644 --- a/src/ZaString/Extensions/ZaSpanStringBuilderExtensions.cs +++ b/src/ZaString/Extensions/ZaSpanStringBuilderExtensions.cs @@ -1,4 +1,5 @@ using System.Globalization; +using System.Runtime.CompilerServices; using ZaString.Core; namespace ZaString.Extensions; @@ -8,6 +9,26 @@ namespace ZaString.Extensions; /// public static class ZaSpanStringBuilderExtensions { + /// + /// Appends an interpolated string using a custom handler that writes directly into the builder. + /// + public static ref ZaSpanStringBuilder Append(ref this ZaSpanStringBuilder builder, + [InterpolatedStringHandlerArgument("builder")] ZaInterpolatedStringHandler handler) + { + builder = handler.GetResult(); + return ref builder; + } + + /// + /// Appends an interpolated string followed by the default line terminator. + /// + public static ref ZaSpanStringBuilder AppendLine(ref this ZaSpanStringBuilder builder, + [InterpolatedStringHandlerArgument("builder")] ZaInterpolatedStringHandler handler) + { + builder = handler.GetResult(); + return ref builder.AppendLine(); + } + /// /// Attempts to reserve a writable span of the specified size without throwing. /// On success, caller must write up to characters and then call with the actual number written. diff --git a/tests/ZaString.Tests/ZaSpanStringBuilderInterpolationTests.cs b/tests/ZaString.Tests/ZaSpanStringBuilderInterpolationTests.cs new file mode 100644 index 0000000..3cd9339 --- /dev/null +++ b/tests/ZaString.Tests/ZaSpanStringBuilderInterpolationTests.cs @@ -0,0 +1,47 @@ +using System.Globalization; +using ZaString.Core; +using ZaString.Extensions; + +namespace ZaString.Tests; + +public class ZaSpanStringBuilderInterpolationTests +{ + [Fact] + public void Append_InterpolatedString_WritesLiteralAndFormatted() + { + Span buffer = stackalloc char[64]; + var builder = ZaSpanStringBuilder.Create(buffer); + + builder.Append($"Hello {{world}}: {123} {1.5}"); + + Assert.Equal("Hello {world}: 123 1.5", builder.AsSpan()); + } + + [Fact] + public void Append_InterpolatedString_WithFormat_AndCulture() + { + Span buffer = stackalloc char[64]; + var builder = ZaSpanStringBuilder.Create(buffer); + + builder.Append($"Hex: {255:X2}"); + Assert.Equal("Hex: FF", builder.AsSpan()); + + builder.Clear(); + + var fr = new CultureInfo("fr-FR"); + builder.Append($"FR: {1.5}"); // handler defaults to invariant; test explicit provider + Assert.Equal("FR: 1.5", builder.AsSpan()); + } + + [Fact] + public void AppendLine_InterpolatedString_AppendsNewline() + { + Span buffer = stackalloc char[64]; + var builder = ZaSpanStringBuilder.Create(buffer); + + builder.AppendLine($"Line: {42}"); + + Assert.Equal($"Line: 42{Environment.NewLine}", builder.AsSpan()); + } +} + From f56221585168bc0910de49b10102643478eb9f5c Mon Sep 17 00:00:00 2001 From: Corentin Giaufer Saubert <43623834+CorentinGS@users.noreply.github.com> Date: Thu, 7 Aug 2025 22:44:35 +0200 Subject: [PATCH 06/13] feat: Add JSON, HTML, and CSV escaping methods to ZaSpanStringBuilder with corresponding demo and tests --- samples/ZaString.Demo/Program.cs | 36 ++++ .../ZaSpanStringBuilderExtensions.cs | 203 ++++++++++++++++++ .../ZaSpanStringBuilderEscapingTests.cs | 52 +++++ 3 files changed, 291 insertions(+) create mode 100644 tests/ZaString.Tests/ZaSpanStringBuilderEscapingTests.cs diff --git a/samples/ZaString.Demo/Program.cs b/samples/ZaString.Demo/Program.cs index 2a6499c..55b3e27 100644 --- a/samples/ZaString.Demo/Program.cs +++ b/samples/ZaString.Demo/Program.cs @@ -47,6 +47,9 @@ public static void Main() InterpolationDemo(); Console.WriteLine(); + JsonEscapingDemo(); + Console.WriteLine(); + Console.WriteLine("Demo complete!"); } @@ -121,6 +124,39 @@ private static void InterpolationDemo() Console.WriteLine(builder.AsSpan().ToString()); } + private static void JsonEscapingDemo() + { + Console.WriteLine("--- JSON Escaping ---"); + + // Build a small JSON object with escaped string values + Span buffer = stackalloc char[256]; + var builder = ZaSpanStringBuilder.Create(buffer); + + var name = "Alice \"A\"\n\t"; + var message = "Line1\r\nLine2\t\"Quote\""; + + builder.Append('{') + .Append("\"name\":\"") + .AppendJsonEscaped(name) + .Append("\",\"message\":\"") + .AppendJsonEscaped(message) + .Append("\"}"); + + Console.WriteLine(builder.AsSpan().ToString()); + + // Demonstrate non-throwing variant + Span tiny = stackalloc char[10]; + var b2 = ZaSpanStringBuilder.Create(tiny); + var ok = b2.TryAppendJsonEscaped(message); + Console.WriteLine($"TryAppendJsonEscaped ok={ok}, len={b2.Length}"); + + // Success case: allocate a conservatively sized buffer and retry + Span big = stackalloc char[message.Length * 6]; // safe upper-bound for escaping + var b3 = ZaSpanStringBuilder.Create(big); + var ok2 = b3.TryAppendJsonEscaped(message); + Console.WriteLine($"TryAppendJsonEscaped ok={ok2}, value='{b3.AsSpan().ToString()}'"); + } + private static void BasicUsageDemo() { Console.WriteLine("--- Basic Usage ---"); diff --git a/src/ZaString/Extensions/ZaSpanStringBuilderExtensions.cs b/src/ZaString/Extensions/ZaSpanStringBuilderExtensions.cs index b389bb6..f6ccc8f 100644 --- a/src/ZaString/Extensions/ZaSpanStringBuilderExtensions.cs +++ b/src/ZaString/Extensions/ZaSpanStringBuilderExtensions.cs @@ -535,4 +535,207 @@ private static void ThrowOutOfRangeException() { throw new ArgumentOutOfRangeException("value", "The destination buffer is too small."); } + + // Escaping helpers + + public static ref ZaSpanStringBuilder AppendJsonEscaped(ref this ZaSpanStringBuilder builder, ReadOnlySpan value) + { + if (!TryAppendJsonEscaped(ref builder, value)) + { + ThrowOutOfRangeException(); + } + return ref builder; + } + + public static bool TryAppendJsonEscaped(ref this ZaSpanStringBuilder builder, ReadOnlySpan value) + { + var required = GetJsonEscapedLength(value); + if (required > builder.RemainingSpan.Length) + { + return false; + } + if (required == value.Length) + { + value.CopyTo(builder.RemainingSpan); + builder.Advance(value.Length); + return true; + } + 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; + 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); + return true; + } + + private static int GetJsonEscapedLength(ReadOnlySpan value) + { + var extra = 0; + for (var i = 0; i < value.Length; i++) + { + var c = value[i]; + switch (c) + { + case '"': + case '\\': + case '\b': + case '\f': + case '\n': + case '\r': + case '\t': + extra += 1; // becomes two chars instead of one + break; + default: + if (c < ' ') + { + extra += 5; // \u00XX adds 5 extra over the original 1 + } + break; + } + } + return value.Length + extra; + } + + private static void WriteHexByte(byte b, Span dest) + { + const string hex = "0123456789ABCDEF"; + dest[0] = hex[(b >> 4) & 0xF]; + dest[1] = hex[b & 0xF]; + } + + public static ref ZaSpanStringBuilder AppendHtmlEscaped(ref this ZaSpanStringBuilder builder, ReadOnlySpan value) + { + if (!TryAppendHtmlEscaped(ref builder, value)) + { + ThrowOutOfRangeException(); + } + return ref builder; + } + + public static bool TryAppendHtmlEscaped(ref this ZaSpanStringBuilder builder, ReadOnlySpan value) + { + var required = GetHtmlEscapedLength(value); + if (required > builder.RemainingSpan.Length) + { + return false; + } + if (required == value.Length) + { + value.CopyTo(builder.RemainingSpan); + builder.Advance(value.Length); + return true; + } + var dest = builder.RemainingSpan; + var w = 0; + for (var i = 0; i < value.Length; i++) + { + switch (value[i]) + { + 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++] = value[i]; break; + } + } + builder.Advance(required); + return true; + } + + private static int GetHtmlEscapedLength(ReadOnlySpan value) + { + var extra = 0; + for (var i = 0; i < value.Length; i++) + { + switch (value[i]) + { + case '&': extra += 4; break; // & (5) - 1 original = +4 + case '<': + case '>': extra += 3; break; // < or > (4) -1 = +3 + case '"': extra += 5; break; // " (6) -1 = +5 + case '\'': extra += 4; break; // ' (5) -1 = +4 + } + } + return value.Length + extra; + } + + public static ref ZaSpanStringBuilder AppendCsvEscaped(ref this ZaSpanStringBuilder builder, ReadOnlySpan value) + { + if (!TryAppendCsvEscaped(ref builder, value)) + { + ThrowOutOfRangeException(); + } + return ref builder; + } + + public static bool TryAppendCsvEscaped(ref this ZaSpanStringBuilder builder, ReadOnlySpan value) + { + var needsQuote = NeedsCsvQuoting(value); + if (!needsQuote) + { + return builder.TryAppend(value); + } + var quoteCount = 0; + for (var i = 0; i < value.Length; i++) if (value[i] == '"') quoteCount++; + var required = value.Length + quoteCount + 2; + if (required > builder.RemainingSpan.Length) + { + return false; + } + var dest = builder.RemainingSpan; + var w = 0; + dest[w++] = '"'; + for (var i = 0; i < value.Length; i++) + { + var c = value[i]; + dest[w++] = c; + if (c == '"') + { + dest[w++] = '"'; + } + } + dest[w++] = '"'; + builder.Advance(required); + return true; + } + + private static bool NeedsCsvQuoting(ReadOnlySpan value) + { + if (value.Length == 0) return false; + if (char.IsWhiteSpace(value[0]) || char.IsWhiteSpace(value[^1])) return true; + for (var i = 0; i < value.Length; i++) + { + var c = value[i]; + if (c == ',' || c == '"' || c == '\n' || c == '\r') return true; + } + return false; + } } \ No newline at end of file diff --git a/tests/ZaString.Tests/ZaSpanStringBuilderEscapingTests.cs b/tests/ZaString.Tests/ZaSpanStringBuilderEscapingTests.cs new file mode 100644 index 0000000..42c4aa0 --- /dev/null +++ b/tests/ZaString.Tests/ZaSpanStringBuilderEscapingTests.cs @@ -0,0 +1,52 @@ +using ZaString.Core; +using ZaString.Extensions; + +namespace ZaString.Tests; + +public class ZaSpanStringBuilderEscapingTests +{ + [Fact] + public void AppendJsonEscaped_Basic() + { + Span buffer = stackalloc char[64]; + var builder = ZaSpanStringBuilder.Create(buffer); + + builder.AppendJsonEscaped("\"Hello\n\tWorld\""); + + Assert.Equal("\\\"Hello\\n\\tWorld\\\"", builder.AsSpan()); + } + + [Fact] + public void TryAppendJsonEscaped_ControlChars_Unicode() + { + Span buffer = stackalloc char[64]; + var builder = ZaSpanStringBuilder.Create(buffer); + + var ok = builder.TryAppendJsonEscaped("A\u0001B"); + Assert.True(ok); + Assert.Equal("A\\u0001B", builder.AsSpan()); + } + + [Fact] + public void AppendHtmlEscaped_Basic() + { + Span buffer = stackalloc char[64]; + var builder = ZaSpanStringBuilder.Create(buffer); + + builder.AppendHtmlEscaped("'x' & y"); + + Assert.Equal("<a href="#">'x' & y</a>", builder.AsSpan()); + } + + [Fact] + public void TryAppendCsvEscaped_Quotes_Commas_Newlines() + { + Span buffer = stackalloc char[128]; + var builder = ZaSpanStringBuilder.Create(buffer); + + var ok = builder.TryAppendCsvEscaped(" a,\"b\"\n"); + Assert.True(ok); + Assert.Equal("\" a,\"\"b\"\"\n\"", builder.AsSpan()); + } +} + From 196f38a5cf8f2d5cb5e187e510a44d4b0a156a4b Mon Sep 17 00:00:00 2001 From: Corentin Giaufer Saubert <43623834+CorentinGS@users.noreply.github.com> Date: Thu, 7 Aug 2025 22:50:27 +0200 Subject: [PATCH 07/13] feat: Add URL encoding and path/query parameter helpers to ZaSpanStringBuilder with corresponding demo and tests --- samples/ZaString.Demo/Program.cs | 18 ++ .../ZaSpanStringBuilderExtensions.cs | 165 ++++++++++++++++++ .../ZaSpanStringBuilderUrlHelpersTests.cs | 55 ++++++ 3 files changed, 238 insertions(+) create mode 100644 tests/ZaString.Tests/ZaSpanStringBuilderUrlHelpersTests.cs diff --git a/samples/ZaString.Demo/Program.cs b/samples/ZaString.Demo/Program.cs index 55b3e27..815b2df 100644 --- a/samples/ZaString.Demo/Program.cs +++ b/samples/ZaString.Demo/Program.cs @@ -50,6 +50,9 @@ public static void Main() JsonEscapingDemo(); Console.WriteLine(); + UrlHelpersDemo(); + Console.WriteLine(); + Console.WriteLine("Demo complete!"); } @@ -157,6 +160,21 @@ private static void JsonEscapingDemo() Console.WriteLine($"TryAppendJsonEscaped ok={ok2}, value='{b3.AsSpan().ToString()}'"); } + private static void UrlHelpersDemo() + { + Console.WriteLine("--- URL Helpers ---"); + + Span buffer = stackalloc char[256]; + var builder = ZaSpanStringBuilder.Create(buffer); + + // Path composition ensures single separators + builder.AppendPathSegment("api").AppendPathSegment("/v1/").AppendPathSegment("users"); + builder.AppendQueryParam("q", "a b", isFirst: true); + builder.AppendQueryParam("tag", "c#"); + + Console.WriteLine(builder.AsSpan().ToString()); // api/v1/users?q=a%20b&tag=c%23 + } + private static void BasicUsageDemo() { Console.WriteLine("--- Basic Usage ---"); diff --git a/src/ZaString/Extensions/ZaSpanStringBuilderExtensions.cs b/src/ZaString/Extensions/ZaSpanStringBuilderExtensions.cs index f6ccc8f..3165f57 100644 --- a/src/ZaString/Extensions/ZaSpanStringBuilderExtensions.cs +++ b/src/ZaString/Extensions/ZaSpanStringBuilderExtensions.cs @@ -1,6 +1,7 @@ using System.Globalization; using System.Runtime.CompilerServices; using ZaString.Core; +using System.Text; namespace ZaString.Extensions; @@ -738,4 +739,168 @@ private static bool NeedsCsvQuoting(ReadOnlySpan value) } return false; } + + // URL encoding and composition helpers + + private static bool IsUnreservedAscii(char c) + { + return (c >= 'A' && c <= 'Z') || + (c >= 'a' && c <= 'z') || + (c >= '0' && c <= '9') || + c is '-' or '_' or '.' or '~'; + } + + public static ref ZaSpanStringBuilder AppendUrlEncoded(ref this ZaSpanStringBuilder builder, ReadOnlySpan value) + { + if (!TryAppendUrlEncoded(ref builder, value)) + { + ThrowOutOfRangeException(); + } + return ref builder; + } + + public static bool TryAppendUrlEncoded(ref this ZaSpanStringBuilder builder, ReadOnlySpan value) + { + var required = GetUrlEncodedLength(value); + if (required > builder.RemainingSpan.Length) + { + return false; + } + + var dest = builder.RemainingSpan; + var w = 0; + for (int 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 high = c; + var low = value[++i]; + var codePoint = 0x10000 + (((high - 0xD800) << 10) | (low - 0xDC00)); + w += PercentEncodeUtf8FromCodePoint(codePoint, dest.Slice(w)); + } + else + { + var codePoint = (int)c; + w += PercentEncodeUtf8FromCodePoint(codePoint, dest.Slice(w)); + } + } + + builder.Advance(required); + return true; + } + + private static int GetUrlEncodedLength(ReadOnlySpan value) + { + var length = 0; + for (int 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; // 4 UTF-8 bytes -> %HH %HH %HH %HH + i++; // consume low surrogate + } + else + { + // Non-surrogate BMP char: 0x80..0x7FF => 2 bytes; 0x800..0xFFFF => 3 bytes + length += (c <= 0x7FF) ? 2 * 3 : 3 * 3; + } + } + return length; + } + + private static int PercentEncodeUtf8FromCodePoint(int codePoint, Span dest) + { + // Returns number of chars written to dest (multiple of 3) + if (codePoint <= 0x7F) + { + dest[0] = '%'; + WriteHexByte((byte)codePoint, dest.Slice(1, 2)); + return 3; + } + if (codePoint <= 0x7FF) + { + var b1 = (byte)(0b1100_0000 | (codePoint >> 6)); + var b2 = (byte)(0b1000_0000 | (codePoint & 0b0011_1111)); + dest[0] = '%'; WriteHexByte(b1, dest.Slice(1, 2)); + dest[3] = '%'; WriteHexByte(b2, dest.Slice(4, 2)); + return 6; + } + if (codePoint <= 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)); + dest[0] = '%'; WriteHexByte(b1, dest.Slice(1, 2)); + dest[3] = '%'; WriteHexByte(b2, dest.Slice(4, 2)); + dest[6] = '%'; WriteHexByte(b3, dest.Slice(7, 2)); + return 9; + } + else + { + 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)); + dest[0] = '%'; WriteHexByte(b1, dest.Slice(1, 2)); + dest[3] = '%'; WriteHexByte(b2, dest.Slice(4, 2)); + dest[6] = '%'; WriteHexByte(b3, dest.Slice(7, 2)); + dest[9] = '%'; WriteHexByte(b4, dest.Slice(10, 2)); + return 12; + } + } + + public static ref ZaSpanStringBuilder AppendPathSegment(ref this ZaSpanStringBuilder builder, ReadOnlySpan segment, char separator = '/') + { + if (builder.Length > 0 && builder[builder.Length - 1] != separator) + { + builder.Append(separator); + } + + // Trim leading separators in segment + int start = 0; + while (start < segment.Length && segment[start] == separator) start++; + if (start < segment.Length) + { + builder.Append(segment[start..]); + } + return ref builder; + } + + public static ref ZaSpanStringBuilder AppendQueryParam(ref this ZaSpanStringBuilder builder, ReadOnlySpan key, ReadOnlySpan value, bool urlEncode = true, bool isFirst = false) + { + builder.Append(isFirst ? '?' : '&'); + if (urlEncode) + { + builder.AppendUrlEncoded(key); + builder.Append('='); + builder.AppendUrlEncoded(value); + } + else + { + builder.Append(key); + builder.Append('='); + builder.Append(value); + } + + return ref builder; + } } \ No newline at end of file diff --git a/tests/ZaString.Tests/ZaSpanStringBuilderUrlHelpersTests.cs b/tests/ZaString.Tests/ZaSpanStringBuilderUrlHelpersTests.cs new file mode 100644 index 0000000..8eb9e03 --- /dev/null +++ b/tests/ZaString.Tests/ZaSpanStringBuilderUrlHelpersTests.cs @@ -0,0 +1,55 @@ +using ZaString.Core; +using ZaString.Extensions; + +namespace ZaString.Tests; + +public class ZaSpanStringBuilderUrlHelpersTests +{ + [Fact] + public void AppendUrlEncoded_Ascii_Unreserved_Untouched() + { + Span buffer = stackalloc char[32]; + var builder = ZaSpanStringBuilder.Create(buffer); + + builder.AppendUrlEncoded("abc-_.~123"); + + Assert.Equal("abc-_.~123", builder.AsSpan()); + } + + [Fact] + public void AppendUrlEncoded_Reserved_And_NonAscii_PercentEncoded() + { + Span buffer = stackalloc char[64]; + var builder = ZaSpanStringBuilder.Create(buffer); + + builder.AppendUrlEncoded("a b/€"); + + // space -> %20, slash -> %2F, Euro (UTF-8: E2 82 AC) -> %E2%82%AC + Assert.Equal("a%20b%2F%E2%82%AC", builder.AsSpan()); + } + + [Fact] + public void AppendPathSegment_Joins_With_Single_Separator() + { + Span buffer = stackalloc char[32]; + var builder = ZaSpanStringBuilder.Create(buffer); + + builder.AppendPathSegment("api").AppendPathSegment("/v1/").AppendPathSegment("users"); + + Assert.Equal("api/v1/users", builder.AsSpan()); + } + + [Fact] + public void AppendQueryParam_Encodes_And_Uses_Correct_Delimiters() + { + Span buffer = stackalloc char[64]; + var builder = ZaSpanStringBuilder.Create(buffer); + + builder.Append("/search") + .AppendQueryParam("q", "a b", urlEncode: true, isFirst: true) + .AppendQueryParam("page", "1", urlEncode: false); + + Assert.Equal("/search?q=a%20b&page=1", builder.AsSpan()); + } +} + From 70d85f35abfd61547041ab3c6f4db335e162fa1e Mon Sep 17 00:00:00 2001 From: Corentin Giaufer Saubert <43623834+CorentinGS@users.noreply.github.com> Date: Thu, 7 Aug 2025 22:56:02 +0200 Subject: [PATCH 08/13] feat: Introduce ZaPooledStringBuilder for efficient string building with pooled memory management, including demo and tests --- samples/ZaString.Demo/Program.cs | 13 ++ src/ZaString/Core/ZaPooledStringBuilder.cs | 123 ++++++++++++++++++ .../ZaPooledStringBuilderTests.cs | 34 +++++ 3 files changed, 170 insertions(+) create mode 100644 src/ZaString/Core/ZaPooledStringBuilder.cs create mode 100644 tests/ZaString.Tests/ZaPooledStringBuilderTests.cs diff --git a/samples/ZaString.Demo/Program.cs b/samples/ZaString.Demo/Program.cs index 815b2df..e3d86da 100644 --- a/samples/ZaString.Demo/Program.cs +++ b/samples/ZaString.Demo/Program.cs @@ -53,6 +53,9 @@ public static void Main() UrlHelpersDemo(); Console.WriteLine(); + PooledBuilderDemo(); + Console.WriteLine(); + Console.WriteLine("Demo complete!"); } @@ -175,6 +178,16 @@ private static void UrlHelpersDemo() Console.WriteLine(builder.AsSpan().ToString()); // api/v1/users?q=a%20b&tag=c%23 } + private static void PooledBuilderDemo() + { + Console.WriteLine("--- Pooled Builder ---"); + + using var b = ZaPooledStringBuilder.Rent(initialCapacity: 8); + b.Append("Hello").Append(", ").Append("World!").Append(' ').Append(123); + Console.WriteLine(b.AsSpan().ToString()); + Console.WriteLine(b.ToString()); + } + private static void BasicUsageDemo() { Console.WriteLine("--- Basic Usage ---"); diff --git a/src/ZaString/Core/ZaPooledStringBuilder.cs b/src/ZaString/Core/ZaPooledStringBuilder.cs new file mode 100644 index 0000000..83c6ea6 --- /dev/null +++ b/src/ZaString/Core/ZaPooledStringBuilder.cs @@ -0,0 +1,123 @@ +using System.Buffers; +using System.Globalization; + +namespace ZaString.Core; + +/// +/// A growable, pooled string builder that minimizes allocations by renting buffers from ArrayPool. +/// +public sealed class ZaPooledStringBuilder : IDisposable +{ + private char[] _buffer; + private int _length; + private readonly ArrayPool _pool; + private bool _disposed; + + private ZaPooledStringBuilder(ArrayPool pool, int initialCapacity) + { + _pool = pool; + _buffer = pool.Rent(Math.Max(1, initialCapacity)); + _length = 0; + } + + public static ZaPooledStringBuilder Rent(int initialCapacity = 256, ArrayPool? pool = null) + { + return new ZaPooledStringBuilder(pool ?? ArrayPool.Shared, initialCapacity); + } + + public int Length => _length; + public int Capacity => _buffer.Length; + + public ReadOnlySpan AsSpan() => _buffer.AsSpan(0, _length); + + public override string ToString() => new string(_buffer, 0, _length); + + public void Clear() => _length = 0; + + public void Dispose() + { + if (_disposed) return; + _disposed = true; + var buf = _buffer; + _buffer = Array.Empty(); + _pool.Return(buf); + } + + private void EnsureCapacity(int additionalRequired) + { + if (additionalRequired < 0) throw new ArgumentOutOfRangeException(nameof(additionalRequired)); + var required = _length + additionalRequired; + if (required <= _buffer.Length) return; + + var newCapacity = _buffer.Length; + if (newCapacity == 0) newCapacity = 1; + while (newCapacity < required) + { + newCapacity = newCapacity * 2; + } + + var newBuffer = _pool.Rent(newCapacity); + _buffer.AsSpan(0, _length).CopyTo(newBuffer); + _pool.Return(_buffer); + _buffer = newBuffer; + } + + public ZaPooledStringBuilder Append(ReadOnlySpan value) + { + EnsureCapacity(value.Length); + value.CopyTo(_buffer.AsSpan(_length)); + _length += value.Length; + return this; + } + + public ZaPooledStringBuilder Append(string? value) + { + if (!string.IsNullOrEmpty(value)) + { + Append(value.AsSpan()); + } + return this; + } + + public ZaPooledStringBuilder Append(char value) + { + EnsureCapacity(1); + _buffer[_length++] = value; + return this; + } + + public ZaPooledStringBuilder Append(bool value) + { + return Append(value ? "true" : "false"); + } + + public ZaPooledStringBuilder Append(T value, ReadOnlySpan format = default, IFormatProvider? provider = null) where T : ISpanFormattable + { + provider ??= CultureInfo.InvariantCulture; + while (true) + { + if (value.TryFormat(_buffer.AsSpan(_length), out var written, format, provider)) + { + _length += written; + return this; + } + // Grow and retry + EnsureCapacity(Math.Max(1, _buffer.Length)); + } + } + + public ZaPooledStringBuilder AppendLine() + { + return Append(Environment.NewLine); + } + + public ZaPooledStringBuilder AppendLine(string? value) + { + if (value is not null) + { + Append(value); + } + return AppendLine(); + } +} + diff --git a/tests/ZaString.Tests/ZaPooledStringBuilderTests.cs b/tests/ZaString.Tests/ZaPooledStringBuilderTests.cs new file mode 100644 index 0000000..1bfbf8c --- /dev/null +++ b/tests/ZaString.Tests/ZaPooledStringBuilderTests.cs @@ -0,0 +1,34 @@ +using ZaString.Core; + +namespace ZaString.Tests; + +public class ZaPooledStringBuilderTests +{ + [Fact] + public void Append_GrowsAndBuilds() + { + using var b = ZaPooledStringBuilder.Rent(initialCapacity: 4); + b.Append("Hello").Append(", ").Append("World!"); + Assert.Equal("Hello, World!", b.AsSpan()); + Assert.Equal("Hello, World!", b.ToString()); + } + + [Fact] + public void Append_Format_Primitives_Invariant() + { + using var b = ZaPooledStringBuilder.Rent(4); + b.Append(255, "X2").Append(' ').Append(1.5); + Assert.Equal("FF 1.5", b.AsSpan()); + } + + [Fact] + public void Clear_ResetsLength() + { + using var b = ZaPooledStringBuilder.Rent(4); + b.Append("abc"); + b.Clear(); + Assert.Equal(0, b.Length); + Assert.Equal("", b.AsSpan()); + } +} + From 54b55bb56c0c5feed23509e0e40edbad1991e59e Mon Sep 17 00:00:00 2001 From: Corentin Giaufer Saubert <43623834+CorentinGS@users.noreply.github.com> Date: Thu, 7 Aug 2025 23:05:13 +0200 Subject: [PATCH 09/13] feat: Implement ZaUtf8SpanWriter for zero-allocation UTF-8 writing with extension methods for various data types, including tests and demo --- samples/ZaString.Demo/Program.cs | 30 ++++ src/ZaString/Core/ZaUtf8SpanWriter.cs | 39 +++++ .../Extensions/ZaUtf8SpanWriterExtensions.cs | 163 ++++++++++++++++++ tests/ZaString.Tests/ZaUtf8SpanWriterTests.cs | 83 +++++++++ 4 files changed, 315 insertions(+) create mode 100644 src/ZaString/Core/ZaUtf8SpanWriter.cs create mode 100644 src/ZaString/Extensions/ZaUtf8SpanWriterExtensions.cs create mode 100644 tests/ZaString.Tests/ZaUtf8SpanWriterTests.cs diff --git a/samples/ZaString.Demo/Program.cs b/samples/ZaString.Demo/Program.cs index e3d86da..d3ddd2e 100644 --- a/samples/ZaString.Demo/Program.cs +++ b/samples/ZaString.Demo/Program.cs @@ -56,6 +56,9 @@ public static void Main() PooledBuilderDemo(); Console.WriteLine(); + Utf8WriterDemo(); + Console.WriteLine(); + Console.WriteLine("Demo complete!"); } @@ -444,4 +447,31 @@ private static void CharacterModificationDemo() Console.WriteLine("✓ IndexOutOfRangeException caught for negative index"); } } + + private static void Utf8WriterDemo() + { + Console.WriteLine("--- UTF-8 Writer Demo ---"); + + // Create a buffer for UTF-8 bytes + Span utf8Buffer = stackalloc byte[1024]; + var writer = ZaUtf8SpanWriter.Create(utf8Buffer); + + // Write some text + writer.Append("Hello, "); + writer.Append("World!"); + writer.Append(" This is a test of "); + writer.Append(123); + writer.Append(" bytes."); + + // Access the written bytes + Console.WriteLine($"Written UTF-8 bytes: {writer.Length}"); + Console.WriteLine(Encoding.UTF8.GetString(writer.AsSpan())); + + // Demonstrate hex and base64 + writer.Clear(); + var data = new byte[] { 0x01, 0x02, 0x03, 0x04 }; + writer.Append("Hex: ").AppendHex(data, uppercase: true); + writer.Append(", Base64: ").AppendBase64(data); + Console.WriteLine(Encoding.UTF8.GetString(writer.AsSpan())); + } } \ No newline at end of file diff --git a/src/ZaString/Core/ZaUtf8SpanWriter.cs b/src/ZaString/Core/ZaUtf8SpanWriter.cs new file mode 100644 index 0000000..4f6e6f8 --- /dev/null +++ b/src/ZaString/Core/ZaUtf8SpanWriter.cs @@ -0,0 +1,39 @@ +using System.Buffers; +using System.Globalization; +using System.Text; + +namespace ZaString.Core; + +/// +/// A zero-allocation UTF-8 writer that writes directly to a provided Span<byte>. +/// This is a ref struct to ensure it is only allocated on the stack. +/// +public ref struct ZaUtf8SpanWriter +{ + private readonly Span _buffer; + public int Length { get; private set; } + public int Capacity => _buffer.Length; + public ReadOnlySpan WrittenSpan => _buffer[..Length]; + public Span RemainingSpan => _buffer[Length..]; + + private ZaUtf8SpanWriter(Span buffer) + { + _buffer = buffer; + Length = 0; + } + + public static ZaUtf8SpanWriter Create(Span buffer) => new(buffer); + + public void Advance(int count) + { + System.Diagnostics.Debug.Assert(count >= 0, "Advance count must be non-negative."); + System.Diagnostics.Debug.Assert(Length + count <= Capacity, "Advance would exceed capacity."); + Length += count; + } + + public void Clear() => Length = 0; + + public ReadOnlySpan AsSpan() => WrittenSpan; + + public override string ToString() => Encoding.UTF8.GetString(WrittenSpan); +} \ No newline at end of file diff --git a/src/ZaString/Extensions/ZaUtf8SpanWriterExtensions.cs b/src/ZaString/Extensions/ZaUtf8SpanWriterExtensions.cs new file mode 100644 index 0000000..b0c344e --- /dev/null +++ b/src/ZaString/Extensions/ZaUtf8SpanWriterExtensions.cs @@ -0,0 +1,163 @@ +using System.Buffers; +using System.Globalization; +using System.Text; +using System.Buffers.Text; +using ZaString.Core; + +namespace ZaString.Extensions; + +/// +/// Provides extension methods for the . +/// +public static class ZaUtf8SpanWriterExtensions +{ + public static ref ZaUtf8SpanWriter Append(ref this ZaUtf8SpanWriter writer, ReadOnlySpan value) + { + if (value.Length > writer.RemainingSpan.Length) + { + ThrowOutOfRangeException(); + } + + value.CopyTo(writer.RemainingSpan); + writer.Advance(value.Length); + return ref writer; + } + + public static ref ZaUtf8SpanWriter Append(ref this ZaUtf8SpanWriter writer, string? value) + { + if (value is not null) + { + var bytes = Encoding.UTF8.GetByteCount(value); + if (bytes > writer.RemainingSpan.Length) + { + ThrowOutOfRangeException(); + } + + var written = Encoding.UTF8.GetBytes(value, writer.RemainingSpan); + writer.Advance(written); + } + + return ref writer; + } + + public static ref ZaUtf8SpanWriter Append(ref this ZaUtf8SpanWriter writer, char value) + { + var bytes = Encoding.UTF8.GetByteCount(stackalloc char[1] { value }); + if (bytes > writer.RemainingSpan.Length) + { + ThrowOutOfRangeException(); + } + + var written = Encoding.UTF8.GetBytes(stackalloc char[1] { value }, writer.RemainingSpan); + writer.Advance(written); + return ref writer; + } + + public static ref ZaUtf8SpanWriter Append(ref this ZaUtf8SpanWriter writer, int value, ReadOnlySpan format = default) + { + var standardFormat = format.Length > 0 ? new StandardFormat(format[0]) : default; + if (!Utf8Formatter.TryFormat(value, writer.RemainingSpan, out var bytesWritten, standardFormat)) + { + ThrowOutOfRangeException(); + } + + writer.Advance(bytesWritten); + return ref writer; + } + + public static ref ZaUtf8SpanWriter Append(ref this ZaUtf8SpanWriter writer, long value, ReadOnlySpan format = default) + { + var standardFormat = format.Length > 0 ? new StandardFormat(format[0]) : default; + if (!Utf8Formatter.TryFormat(value, writer.RemainingSpan, out var bytesWritten, standardFormat)) + { + ThrowOutOfRangeException(); + } + + writer.Advance(bytesWritten); + return ref writer; + } + + public static ref ZaUtf8SpanWriter Append(ref this ZaUtf8SpanWriter writer, double value, ReadOnlySpan format = default) + { + var standardFormat = format.Length > 0 ? new StandardFormat(format[0]) : default; + if (!Utf8Formatter.TryFormat(value, writer.RemainingSpan, out var bytesWritten, standardFormat)) + { + ThrowOutOfRangeException(); + } + + writer.Advance(bytesWritten); + return ref writer; + } + + public static ref ZaUtf8SpanWriter Append(ref this ZaUtf8SpanWriter writer, DateTime value, ReadOnlySpan format = default) + { + var standardFormat = format.Length > 0 ? new StandardFormat(format[0]) : default; + if (!Utf8Formatter.TryFormat(value, writer.RemainingSpan, out var bytesWritten, standardFormat)) + { + ThrowOutOfRangeException(); + } + + writer.Advance(bytesWritten); + return ref writer; + } + + public static ref ZaUtf8SpanWriter AppendLine(ref this ZaUtf8SpanWriter writer) + { + return ref writer.Append(Encoding.UTF8.GetBytes(Environment.NewLine)); + } + + public static ref ZaUtf8SpanWriter AppendLine(ref this ZaUtf8SpanWriter writer, string? value) + { + if (value is not null) + { + writer.Append(value); + } + return ref writer.AppendLine(); + } + + public static ref ZaUtf8SpanWriter AppendHex(ref this ZaUtf8SpanWriter writer, ReadOnlySpan data, bool uppercase = false) + { + var required = data.Length * 2; + if (required > writer.RemainingSpan.Length) + { + ThrowOutOfRangeException(); + } + + var dest = writer.RemainingSpan; + var hex = uppercase ? "0123456789ABCDEF" : "0123456789abcdef"; + var w = 0; + + for (var i = 0; i < data.Length; i++) + { + var b = data[i]; + dest[w++] = (byte)hex[(b >> 4) & 0xF]; + dest[w++] = (byte)hex[b & 0xF]; + } + + writer.Advance(required); + return ref writer; + } + + public static ref ZaUtf8SpanWriter AppendBase64(ref this ZaUtf8SpanWriter writer, ReadOnlySpan data) + { + var required = Base64.GetMaxEncodedToUtf8Length(data.Length); + if (required > writer.RemainingSpan.Length) + { + ThrowOutOfRangeException(); + } + + var status = Base64.EncodeToUtf8(data, writer.RemainingSpan, out var bytesConsumed, out var bytesWritten); + if (status != OperationStatus.Done) + { + ThrowOutOfRangeException(); + } + + writer.Advance(bytesWritten); + return ref writer; + } + + private static void ThrowOutOfRangeException() + { + throw new ArgumentOutOfRangeException("value", "The destination buffer is too small."); + } +} \ No newline at end of file diff --git a/tests/ZaString.Tests/ZaUtf8SpanWriterTests.cs b/tests/ZaString.Tests/ZaUtf8SpanWriterTests.cs new file mode 100644 index 0000000..93d889d --- /dev/null +++ b/tests/ZaString.Tests/ZaUtf8SpanWriterTests.cs @@ -0,0 +1,83 @@ +using System.Text; +using ZaString.Core; +using ZaString.Extensions; + +namespace ZaString.Tests; + +public class ZaUtf8SpanWriterTests +{ + [Fact] + public void Append_String_EncodesToUtf8() + { + Span buffer = stackalloc byte[32]; + var writer = ZaUtf8SpanWriter.Create(buffer); + + writer.Append("Hello"); + + var expected = Encoding.UTF8.GetBytes("Hello"); + Assert.True(writer.AsSpan().SequenceEqual(expected)); + } + + [Fact] + public void Append_Char_EncodesToUtf8() + { + Span buffer = stackalloc byte[8]; + var writer = ZaUtf8SpanWriter.Create(buffer); + + writer.Append('A'); + + var expected = Encoding.UTF8.GetBytes("A"); + Assert.True(writer.AsSpan().SequenceEqual(expected)); + } + + [Fact] + public void Append_Int_FormatsToUtf8() + { + Span buffer = stackalloc byte[16]; + var writer = ZaUtf8SpanWriter.Create(buffer); + + writer.Append(123); + + var expected = Encoding.UTF8.GetBytes("123"); + Assert.True(writer.AsSpan().SequenceEqual(expected)); + } + + [Fact] + public void Append_Double_FormatsToUtf8() + { + Span buffer = stackalloc byte[16]; + var writer = ZaUtf8SpanWriter.Create(buffer); + + writer.Append(3.14); + + var expected = Encoding.UTF8.GetBytes("3.14"); + Assert.True(writer.AsSpan().SequenceEqual(expected)); + } + + [Fact] + public void AppendHex_FormatsBytesAsHex() + { + Span buffer = stackalloc byte[16]; + var writer = ZaUtf8SpanWriter.Create(buffer); + + var data = new byte[] { 0xAB, 0xCD, 0xEF }; + writer.AppendHex(data, uppercase: true); + + var expected = Encoding.UTF8.GetBytes("ABCDEF"); + Assert.True(writer.AsSpan().SequenceEqual(expected)); + } + + [Fact] + public void AppendBase64_EncodesBytes() + { + Span buffer = stackalloc byte[32]; + var writer = ZaUtf8SpanWriter.Create(buffer); + + var data = new byte[] { 0x01, 0x02, 0x03 }; + writer.AppendBase64(data); + + var expected = Convert.ToBase64String(data); + var expectedBytes = Encoding.UTF8.GetBytes(expected); + Assert.True(writer.AsSpan().SequenceEqual(expectedBytes)); + } +} \ No newline at end of file From c07aefb1869506011089b898ece39e22838f0276 Mon Sep 17 00:00:00 2001 From: Corentin Giaufer Saubert <43623834+CorentinGS@users.noreply.github.com> Date: Thu, 7 Aug 2025 23:15:45 +0200 Subject: [PATCH 10/13] feat: Add composite formatting support to ZaSpanStringBuilder with new AppendFormat methods and corresponding demo and tests --- samples/ZaString.Demo/Program.cs | 35 ++++++++++++ .../ZaSpanStringBuilderExtensions.cs | 11 ++++ .../ZaSpanStringBuilderFormatTests.cs | 57 +++++++++++++++++++ 3 files changed, 103 insertions(+) create mode 100644 tests/ZaString.Tests/ZaSpanStringBuilderFormatTests.cs diff --git a/samples/ZaString.Demo/Program.cs b/samples/ZaString.Demo/Program.cs index d3ddd2e..c25d866 100644 --- a/samples/ZaString.Demo/Program.cs +++ b/samples/ZaString.Demo/Program.cs @@ -59,6 +59,9 @@ public static void Main() Utf8WriterDemo(); Console.WriteLine(); + FormatDemo(); + Console.WriteLine(); + Console.WriteLine("Demo complete!"); } @@ -474,4 +477,36 @@ private static void Utf8WriterDemo() writer.Append(", Base64: ").AppendBase64(data); Console.WriteLine(Encoding.UTF8.GetString(writer.AsSpan())); } + + private static void FormatDemo() + { + Console.WriteLine("--- Composite Formatting ---"); + + Span buffer = stackalloc char[128]; + var builder = ZaSpanStringBuilder.Create(buffer); + + var name = "Alice"; + var age = 30; + var pi = Math.PI; + + // Composite formatting using AppendFormat + builder.AppendFormat("User: {0}, Age: {1}, Pi: {2:F2}", name, age, pi); + Console.WriteLine(builder.AsSpan().ToString()); + + // Clear and demonstrate AppendFormat with culture-specific formatting + builder.Clear(); + var fr = new CultureInfo("fr-FR"); + builder.AppendFormat(fr, "User: {0}, Age: {1}, Pi: {2:F2}", name, age, pi); + Console.WriteLine(builder.AsSpan().ToString()); + + // Clear and demonstrate AppendFormat with custom formatting + builder.Clear(); + builder.AppendFormat(fr, "Currency: {0:C}", 1234.56); + Console.WriteLine(builder.AsSpan().ToString()); + + // Clear and demonstrate AppendFormat with multiple arguments + builder.Clear(); + builder.AppendFormat(fr, "User: {0}, Age: {1}, Pi: {2:F2}, Currency: {3:C}", name, age, pi, 1234.56); + Console.WriteLine(builder.AsSpan().ToString()); + } } \ No newline at end of file diff --git a/src/ZaString/Extensions/ZaSpanStringBuilderExtensions.cs b/src/ZaString/Extensions/ZaSpanStringBuilderExtensions.cs index 3165f57..f90c37b 100644 --- a/src/ZaString/Extensions/ZaSpanStringBuilderExtensions.cs +++ b/src/ZaString/Extensions/ZaSpanStringBuilderExtensions.cs @@ -903,4 +903,15 @@ public static ref ZaSpanStringBuilder AppendQueryParam(ref this ZaSpanStringBuil return ref builder; } + + public static ref ZaSpanStringBuilder AppendFormat(ref this ZaSpanStringBuilder builder, string format, params object?[] args) + { + return ref builder.AppendFormat(CultureInfo.InvariantCulture, format, args); + } + + public static ref ZaSpanStringBuilder AppendFormat(ref this ZaSpanStringBuilder builder, IFormatProvider? formatProvider, string format, params object?[] args) + { + var formatted = string.Format(formatProvider, format, args); + return ref builder.Append(formatted.AsSpan()); + } } \ No newline at end of file diff --git a/tests/ZaString.Tests/ZaSpanStringBuilderFormatTests.cs b/tests/ZaString.Tests/ZaSpanStringBuilderFormatTests.cs new file mode 100644 index 0000000..a2dfa2b --- /dev/null +++ b/tests/ZaString.Tests/ZaSpanStringBuilderFormatTests.cs @@ -0,0 +1,57 @@ +using System.Globalization; +using ZaString.Core; +using ZaString.Extensions; + +namespace ZaString.Tests; + +public class ZaSpanStringBuilderFormatTests +{ + [Fact] + public void AppendFormat_WithParameters_FormatsCorrectly() + { + Span buffer = stackalloc char[256]; + var builder = ZaSpanStringBuilder.Create(buffer); + + builder.AppendFormat("User: {0}, Balance: {1:C}, Active: {2}", "John", 1234.56, true); + + var expected = string.Format(CultureInfo.InvariantCulture, "User: {0}, Balance: {1:C}, Active: {2}", "John", 1234.56, true); + Assert.Equal(expected, builder.AsSpan().ToString()); + } + + [Fact] + public void AppendFormat_WithCulture_FormatsWithCulture() + { + Span buffer = stackalloc char[256]; + var builder = ZaSpanStringBuilder.Create(buffer); + + var culture = new CultureInfo("fr-FR"); + builder.AppendFormat(culture, "Balance: {0:C}", 1234.56); + + var expected = string.Format(culture, "Balance: {0:C}", 1234.56); + Assert.Equal(expected, builder.AsSpan().ToString()); + } + + [Fact] + public void AppendFormat_WithNullValues_HandlesNulls() + { + Span buffer = stackalloc char[256]; + var builder = ZaSpanStringBuilder.Create(buffer); + + builder.AppendFormat("Null: {0}, Text: {1}, Another null: {2}", null, "test", null); + + var expected = string.Format("Null: {0}, Text: {1}, Another null: {2}", null, "test", null); + Assert.Equal(expected, builder.AsSpan().ToString()); + } + + [Fact] + public void AppendFormat_WithISpanFormattable_UsesSpanFormattable() + { + Span buffer = stackalloc char[256]; + var builder = ZaSpanStringBuilder.Create(buffer); + + builder.AppendFormat("Int: {0:N0}, Double: {1:F2}", 123, 456.789); + + var expected = string.Format("Int: {0:N0}, Double: {1:F2}", 123, 456.789); + Assert.Equal(expected, builder.AsSpan().ToString()); + } +} \ No newline at end of file From ee7439494b6b4b04b8e62ef2b1b1b9713e61ed47 Mon Sep 17 00:00:00 2001 From: Corentin Giaufer Saubert <43623834+CorentinGS@users.noreply.github.com> Date: Thu, 7 Aug 2025 23:26:17 +0200 Subject: [PATCH 11/13] refactor: Improve ZaPooledStringBuilder and ZaSpanStringBuilder with code cleanup, enhanced readability, and consistent formatting across methods --- samples/ZaString.Demo/Program.cs | 31 +- src/ZaString/Core/ZaPooledStringBuilder.cs | 69 +- src/ZaString/Core/ZaSpanString.cs | 2 +- src/ZaString/Core/ZaUtf8SpanWriter.cs | 51 +- .../Extensions/ZaInterpolatedStringHandler.cs | 8 +- .../ZaSpanStringBuilderExtensions.cs | 1147 +++++++++-------- .../Extensions/ZaUtf8SpanWriterExtensions.cs | 20 +- .../ZaPooledStringBuilderTests.cs | 5 +- .../ZaSpanStringBuilderAppendHelpersTests.cs | 9 +- .../ZaSpanStringBuilderBasicTests.cs | 2 +- .../ZaSpanStringBuilderEdgeCasesTests.cs | 4 +- .../ZaSpanStringBuilderEscapingTests.cs | 3 +- .../ZaSpanStringBuilderFormatTests.cs | 2 +- .../ZaSpanStringBuilderInterpolationTests.cs | 3 +- ...ZaSpanStringBuilderMutationHelpersTests.cs | 3 +- .../ZaSpanStringBuilderTryAppendTests.cs | 7 +- .../ZaSpanStringBuilderUrlHelpersTests.cs | 7 +- tests/ZaString.Tests/ZaUtf8SpanWriterTests.cs | 18 +- 18 files changed, 769 insertions(+), 622 deletions(-) diff --git a/samples/ZaString.Demo/Program.cs b/samples/ZaString.Demo/Program.cs index c25d866..6e18e22 100644 --- a/samples/ZaString.Demo/Program.cs +++ b/samples/ZaString.Demo/Program.cs @@ -1,6 +1,6 @@ using System.Diagnostics; -using System.Text; using System.Globalization; +using System.Text; using ZaString.Core; using ZaString.Extensions; @@ -111,7 +111,12 @@ private static void AppendHelpersDemo() builder.AppendJoin(", ".AsSpan(), "a", null, "c").AppendLine(); // AppendJoin with ISpanFormattable values and culture - var values = new double[] { 1.5, 2.5, 3.5 }; + var values = new[] + { + 1.5, + 2.5, + 3.5 + }; builder.AppendJoin("; ".AsSpan(), values, provider: new CultureInfo("fr-FR")); Console.WriteLine(builder.AsSpan().ToString()); @@ -148,11 +153,11 @@ private static void JsonEscapingDemo() var message = "Line1\r\nLine2\t\"Quote\""; builder.Append('{') - .Append("\"name\":\"") - .AppendJsonEscaped(name) - .Append("\",\"message\":\"") - .AppendJsonEscaped(message) - .Append("\"}"); + .Append("\"name\":\"") + .AppendJsonEscaped(name) + .Append("\",\"message\":\"") + .AppendJsonEscaped(message) + .Append("\"}"); Console.WriteLine(builder.AsSpan().ToString()); @@ -188,7 +193,7 @@ private static void PooledBuilderDemo() { Console.WriteLine("--- Pooled Builder ---"); - using var b = ZaPooledStringBuilder.Rent(initialCapacity: 8); + using var b = ZaPooledStringBuilder.Rent(8); b.Append("Hello").Append(", ").Append("World!").Append(' ').Append(123); Console.WriteLine(b.AsSpan().ToString()); Console.WriteLine(b.ToString()); @@ -472,8 +477,14 @@ private static void Utf8WriterDemo() // Demonstrate hex and base64 writer.Clear(); - var data = new byte[] { 0x01, 0x02, 0x03, 0x04 }; - writer.Append("Hex: ").AppendHex(data, uppercase: true); + var data = new byte[] + { + 0x01, + 0x02, + 0x03, + 0x04 + }; + writer.Append("Hex: ").AppendHex(data, true); writer.Append(", Base64: ").AppendBase64(data); Console.WriteLine(Encoding.UTF8.GetString(writer.AsSpan())); } diff --git a/src/ZaString/Core/ZaPooledStringBuilder.cs b/src/ZaString/Core/ZaPooledStringBuilder.cs index 83c6ea6..7f873ab 100644 --- a/src/ZaString/Core/ZaPooledStringBuilder.cs +++ b/src/ZaString/Core/ZaPooledStringBuilder.cs @@ -4,60 +4,73 @@ namespace ZaString.Core; /// -/// A growable, pooled string builder that minimizes allocations by renting buffers from ArrayPool. +/// A growable, pooled string builder that minimizes allocations by renting buffers from ArrayPool. /// public sealed class ZaPooledStringBuilder : IDisposable { - private char[] _buffer; - private int _length; private readonly ArrayPool _pool; + private char[] _buffer; private bool _disposed; private ZaPooledStringBuilder(ArrayPool pool, int initialCapacity) { _pool = pool; _buffer = pool.Rent(Math.Max(1, initialCapacity)); - _length = 0; + Length = 0; } - public static ZaPooledStringBuilder Rent(int initialCapacity = 256, ArrayPool? pool = null) + public int Length { get; private set; } + + public int Capacity { - return new ZaPooledStringBuilder(pool ?? ArrayPool.Shared, initialCapacity); + get => _buffer.Length; } - public int Length => _length; - public int Capacity => _buffer.Length; - - public ReadOnlySpan AsSpan() => _buffer.AsSpan(0, _length); - - public override string ToString() => new string(_buffer, 0, _length); - - public void Clear() => _length = 0; - public void Dispose() { if (_disposed) return; _disposed = true; var buf = _buffer; - _buffer = Array.Empty(); + _buffer = []; _pool.Return(buf); } + public static ZaPooledStringBuilder Rent(int initialCapacity = 256, ArrayPool? pool = null) + { + return new ZaPooledStringBuilder(pool ?? ArrayPool.Shared, initialCapacity); + } + + public ReadOnlySpan AsSpan() + { + return _buffer.AsSpan(0, Length); + } + + public override string ToString() + { + return new string(_buffer, 0, Length); + } + + public void Clear() + { + Length = 0; + } + private void EnsureCapacity(int additionalRequired) { - if (additionalRequired < 0) throw new ArgumentOutOfRangeException(nameof(additionalRequired)); - var required = _length + additionalRequired; + ArgumentOutOfRangeException.ThrowIfNegative(additionalRequired); + + var required = Length + additionalRequired; if (required <= _buffer.Length) return; var newCapacity = _buffer.Length; if (newCapacity == 0) newCapacity = 1; while (newCapacity < required) { - newCapacity = newCapacity * 2; + newCapacity *= 2; } var newBuffer = _pool.Rent(newCapacity); - _buffer.AsSpan(0, _length).CopyTo(newBuffer); + _buffer.AsSpan(0, Length).CopyTo(newBuffer); _pool.Return(_buffer); _buffer = newBuffer; } @@ -65,8 +78,8 @@ private void EnsureCapacity(int additionalRequired) public ZaPooledStringBuilder Append(ReadOnlySpan value) { EnsureCapacity(value.Length); - value.CopyTo(_buffer.AsSpan(_length)); - _length += value.Length; + value.CopyTo(_buffer.AsSpan(Length)); + Length += value.Length; return this; } @@ -76,13 +89,14 @@ public ZaPooledStringBuilder Append(string? value) { Append(value.AsSpan()); } + return this; } public ZaPooledStringBuilder Append(char value) { EnsureCapacity(1); - _buffer[_length++] = value; + _buffer[Length++] = value; return this; } @@ -96,11 +110,12 @@ public ZaPooledStringBuilder Append(T value, ReadOnlySpan format = defa provider ??= CultureInfo.InvariantCulture; while (true) { - if (value.TryFormat(_buffer.AsSpan(_length), out var written, format, provider)) + if (value.TryFormat(_buffer.AsSpan(Length), out var written, format, provider)) { - _length += written; + Length += written; return this; } + // Grow and retry EnsureCapacity(Math.Max(1, _buffer.Length)); } @@ -117,7 +132,7 @@ public ZaPooledStringBuilder AppendLine(string? value) { Append(value); } + return AppendLine(); } -} - +} \ No newline at end of file diff --git a/src/ZaString/Core/ZaSpanString.cs b/src/ZaString/Core/ZaSpanString.cs index 0c2c9da..ffeb077 100644 --- a/src/ZaString/Core/ZaSpanString.cs +++ b/src/ZaString/Core/ZaSpanString.cs @@ -117,7 +117,7 @@ public void SetLength(int newLength) } /// - /// Removes the last characters from the written span. + /// Removes the last characters from the written span. /// /// Number of characters to remove. Must be between 0 and current Length. /// Thrown if count is negative or greater than current Length. diff --git a/src/ZaString/Core/ZaUtf8SpanWriter.cs b/src/ZaString/Core/ZaUtf8SpanWriter.cs index 4f6e6f8..54f56f8 100644 --- a/src/ZaString/Core/ZaUtf8SpanWriter.cs +++ b/src/ZaString/Core/ZaUtf8SpanWriter.cs @@ -1,20 +1,31 @@ -using System.Buffers; -using System.Globalization; +using System.Diagnostics; using System.Text; namespace ZaString.Core; /// -/// A zero-allocation UTF-8 writer that writes directly to a provided Span<byte>. -/// This is a ref struct to ensure it is only allocated on the stack. +/// A zero-allocation UTF-8 writer that writes directly to a provided Span<byte>. +/// This is a ref struct to ensure it is only allocated on the stack. /// public ref struct ZaUtf8SpanWriter { private readonly Span _buffer; public int Length { get; private set; } - public int Capacity => _buffer.Length; - public ReadOnlySpan WrittenSpan => _buffer[..Length]; - public Span RemainingSpan => _buffer[Length..]; + + private int Capacity + { + get => _buffer.Length; + } + + public ReadOnlySpan WrittenSpan + { + get => _buffer[..Length]; + } + + public Span RemainingSpan + { + get => _buffer[Length..]; + } private ZaUtf8SpanWriter(Span buffer) { @@ -22,18 +33,30 @@ private ZaUtf8SpanWriter(Span buffer) Length = 0; } - public static ZaUtf8SpanWriter Create(Span buffer) => new(buffer); + public static ZaUtf8SpanWriter Create(Span buffer) + { + return new ZaUtf8SpanWriter(buffer); + } public void Advance(int count) { - System.Diagnostics.Debug.Assert(count >= 0, "Advance count must be non-negative."); - System.Diagnostics.Debug.Assert(Length + count <= Capacity, "Advance would exceed capacity."); + Debug.Assert(count >= 0, "Advance count must be non-negative."); + Debug.Assert(Length + count <= Capacity, "Advance would exceed capacity."); Length += count; } - public void Clear() => Length = 0; + public void Clear() + { + Length = 0; + } - public ReadOnlySpan AsSpan() => WrittenSpan; + public ReadOnlySpan AsSpan() + { + return WrittenSpan; + } - public override string ToString() => Encoding.UTF8.GetString(WrittenSpan); -} \ No newline at end of file + public override string ToString() + { + return Encoding.UTF8.GetString(WrittenSpan); + } +} \ No newline at end of file diff --git a/src/ZaString/Extensions/ZaInterpolatedStringHandler.cs b/src/ZaString/Extensions/ZaInterpolatedStringHandler.cs index 2414729..d233422 100644 --- a/src/ZaString/Extensions/ZaInterpolatedStringHandler.cs +++ b/src/ZaString/Extensions/ZaInterpolatedStringHandler.cs @@ -52,6 +52,8 @@ public void AppendFormatted(T value, string? format) where T : ISpanFormattab _builder.Append(value, format, _provider); } - public ZaSpanStringBuilder GetResult() => _builder; -} - + public ZaSpanStringBuilder GetResult() + { + return _builder; + } +} \ No newline at end of file diff --git a/src/ZaString/Extensions/ZaSpanStringBuilderExtensions.cs b/src/ZaString/Extensions/ZaSpanStringBuilderExtensions.cs index f90c37b..d967a72 100644 --- a/src/ZaString/Extensions/ZaSpanStringBuilderExtensions.cs +++ b/src/ZaString/Extensions/ZaSpanStringBuilderExtensions.cs @@ -1,7 +1,6 @@ using System.Globalization; using System.Runtime.CompilerServices; using ZaString.Core; -using System.Text; namespace ZaString.Extensions; @@ -10,317 +9,315 @@ namespace ZaString.Extensions; /// public static class ZaSpanStringBuilderExtensions { - /// - /// Appends an interpolated string using a custom handler that writes directly into the builder. - /// - public static ref ZaSpanStringBuilder Append(ref this ZaSpanStringBuilder builder, - [InterpolatedStringHandlerArgument("builder")] ZaInterpolatedStringHandler handler) + /// + /// Appends an interpolated string using a custom handler that writes directly into the builder. + /// + public static ref ZaSpanStringBuilder Append(ref this ZaSpanStringBuilder builder, + [InterpolatedStringHandlerArgument("builder")] + ZaInterpolatedStringHandler handler) + { + builder = handler.GetResult(); + return ref builder; + } + + /// + /// Appends an interpolated string followed by the default line terminator. + /// + public static ref ZaSpanStringBuilder AppendLine(ref this ZaSpanStringBuilder builder, + [InterpolatedStringHandlerArgument("builder")] + ZaInterpolatedStringHandler handler) + { + builder = handler.GetResult(); + return ref builder.AppendLine(); + } + + /// + /// Attempts to reserve a writable span of the specified size without throwing. + /// On success, caller must write up to characters and then call + /// with the actual number written. + /// + /// The builder. + /// Requested size to reserve. + /// The span the caller can write into. + /// true if reserved; false if capacity is insufficient. + public static bool TryGetAppendSpan(ref this ZaSpanStringBuilder builder, int size, out Span writeSpan) + { + ArgumentOutOfRangeException.ThrowIfNegative(size); + + if (size == 0) { - builder = handler.GetResult(); - return ref builder; + writeSpan = Span.Empty; + return true; } - /// - /// Appends an interpolated string followed by the default line terminator. - /// - public static ref ZaSpanStringBuilder AppendLine(ref this ZaSpanStringBuilder builder, - [InterpolatedStringHandlerArgument("builder")] ZaInterpolatedStringHandler handler) + if (builder.RemainingSpan.Length < size) { - builder = handler.GetResult(); - return ref builder.AppendLine(); + writeSpan = Span.Empty; + return false; } - /// - /// Attempts to reserve a writable span of the specified size without throwing. - /// On success, caller must write up to characters and then call with the actual number written. - /// - /// The builder. - /// Requested size to reserve. - /// The span the caller can write into. - /// true if reserved; false if capacity is insufficient. - public static bool TryGetAppendSpan(ref this ZaSpanStringBuilder builder, int size, out Span writeSpan) - { - if (size < 0) - { - throw new ArgumentOutOfRangeException(nameof(size)); - } + writeSpan = builder.RemainingSpan[..size]; + return true; + } - if (size == 0) - { - writeSpan = Span.Empty; - return true; - } + /// + /// Reserves a writable span of the specified size or throws if the capacity is insufficient. + /// On success, caller must write up to characters and then call + /// with the actual number written. + /// + public static ref ZaSpanStringBuilder GetAppendSpan(ref this ZaSpanStringBuilder builder, int size, out Span writeSpan) + { + if (!TryGetAppendSpan(ref builder, size, out writeSpan)) + { + ThrowOutOfRangeException(); + } - if (builder.RemainingSpan.Length < size) - { - writeSpan = Span.Empty; - return false; - } + return ref builder; + } - writeSpan = builder.RemainingSpan[..size]; - return true; + /// + /// Removes the last characters from the written span. + /// + public static ref ZaSpanStringBuilder RemoveLast(ref this ZaSpanStringBuilder builder, int count) + { + if (count < 0 || count > builder.Length) + { + throw new ArgumentOutOfRangeException(nameof(count)); } - /// - /// Reserves a writable span of the specified size or throws if the capacity is insufficient. - /// On success, caller must write up to characters and then call with the actual number written. - /// - public static ref ZaSpanStringBuilder GetAppendSpan(ref this ZaSpanStringBuilder builder, int size, out Span writeSpan) + if (count > 0) { - if (!TryGetAppendSpan(ref builder, size, out writeSpan)) - { - ThrowOutOfRangeException(); - } - - return ref builder; + builder.RemoveLast(count); } - /// - /// Removes the last characters from the written span. - /// - public static ref ZaSpanStringBuilder RemoveLast(ref this ZaSpanStringBuilder builder, int count) + return ref builder; + } + + /// + /// Sets the current length to . Must be between 0 and Capacity. + /// If is less than the current Length, the content is logically truncated. + /// + public static ref ZaSpanStringBuilder SetLength(ref this ZaSpanStringBuilder builder, int newLength) + { + if (newLength < 0 || newLength > builder.Length) { - if (count < 0 || count > builder.Length) - { - throw new ArgumentOutOfRangeException(nameof(count)); - } + throw new ArgumentOutOfRangeException(nameof(newLength)); + } - if (count > 0) - { - builder.RemoveLast(count); - } + builder.SetLength(newLength); + return ref builder; + } - return ref builder; + /// + /// Ensures the written span ends with the specified character; appends it if needed. + /// + public static ref ZaSpanStringBuilder EnsureEndsWith(ref this ZaSpanStringBuilder builder, char value) + { + if (builder.Length == 0 || builder[^1] != value) + { + builder.Append(value); } - /// - /// Sets the current length to . Must be between 0 and Capacity. - /// If is less than the current Length, the content is logically truncated. - /// - public static ref ZaSpanStringBuilder SetLength(ref this ZaSpanStringBuilder builder, int newLength) - { - if (newLength < 0 || newLength > builder.Length) - { - throw new ArgumentOutOfRangeException(nameof(newLength)); - } + return ref builder; + } + /// + /// Appends the specified character repeated times. + /// + /// Thrown if count is negative or buffer is too small. + public static ref ZaSpanStringBuilder AppendRepeat(ref this ZaSpanStringBuilder builder, char value, int count) + { + ArgumentOutOfRangeException.ThrowIfNegative(count); - builder.SetLength(newLength); + if (count == 0) + { return ref builder; } - /// - /// Ensures the written span ends with the specified character; appends it if needed. - /// - public static ref ZaSpanStringBuilder EnsureEndsWith(ref this ZaSpanStringBuilder builder, char value) + if (builder.RemainingSpan.Length < count) { - if (builder.Length == 0 || builder[builder.Length - 1] != value) - { - builder.Append(value); - } - - return ref builder; + ThrowOutOfRangeException(); } - /// - /// Appends the specified character repeated times. - /// - /// Thrown if count is negative or buffer is too small. - public static ref ZaSpanStringBuilder AppendRepeat(ref this ZaSpanStringBuilder builder, char value, int count) - { - if (count < 0) - { - throw new ArgumentOutOfRangeException(nameof(count)); - } - if (count == 0) - { - return ref builder; - } + builder.RemainingSpan[..count].Fill(value); + builder.Advance(count); + return ref builder; + } - if (builder.RemainingSpan.Length < count) - { - ThrowOutOfRangeException(); - } + /// + /// Attempts to append the specified character repeated times without throwing. + /// + /// true if appended; otherwise false when capacity is insufficient. + public static bool TryAppendRepeat(ref this ZaSpanStringBuilder builder, char value, int count) + { + ArgumentOutOfRangeException.ThrowIfNegative(count); - builder.RemainingSpan[..count].Fill(value); - builder.Advance(count); - return ref builder; + if (count == 0) + { + return true; } - /// - /// Attempts to append the specified character repeated times without throwing. - /// - /// true if appended; otherwise false when capacity is insufficient. - public static bool TryAppendRepeat(ref this ZaSpanStringBuilder builder, char value, int count) + if (builder.RemainingSpan.Length < count) { - if (count < 0) - { - throw new ArgumentOutOfRangeException(nameof(count)); - } + return false; + } - if (count == 0) - { - return true; - } + builder.RemainingSpan[..count].Fill(value); + builder.Advance(count); + return true; + } - if (builder.RemainingSpan.Length < count) + /// + /// Appends the elements separated by . + /// Null elements are treated as empty strings. + /// + public static ref ZaSpanStringBuilder AppendJoin(ref this ZaSpanStringBuilder builder, ReadOnlySpan separator, params string?[] values) + { + for (var i = 0; i < values.Length; i++) + { + if (i > 0) { - return false; + builder.Append(separator); } - builder.RemainingSpan[..count].Fill(value); - builder.Advance(count); - return true; - } - - /// - /// Appends the elements separated by . - /// Null elements are treated as empty strings. - /// - public static ref ZaSpanStringBuilder AppendJoin(ref this ZaSpanStringBuilder builder, ReadOnlySpan separator, params string?[] values) - { - for (var i = 0; i < values.Length; i++) + var s = values[i]; + if (s is not null) { - if (i > 0) - { - builder.Append(separator); - } - - var s = values[i]; - if (s is not null) - { - builder.Append(s); - } + builder.Append(s); } - - return ref builder; } - /// - /// Appends the elements of separated by . - /// - public static ref ZaSpanStringBuilder AppendJoin(ref this ZaSpanStringBuilder builder, ReadOnlySpan separator, ReadOnlySpan values, ReadOnlySpan format = default, IFormatProvider? provider = null) - where T : ISpanFormattable - { - for (var i = 0; i < values.Length; i++) - { - if (i > 0) - { - builder.Append(separator); - } - - builder.Append(values[i], format, provider); - } + return ref builder; + } - return ref builder; - } - /// - /// Attempts to append a read-only span of characters to the builder without throwing. - /// - /// The builder instance. - /// The span of characters to append. - /// true if the value was appended; otherwise false if there was not enough capacity. - public static bool TryAppend(ref this ZaSpanStringBuilder builder, ReadOnlySpan value) + /// + /// Appends the elements of separated by . + /// + public static ref ZaSpanStringBuilder AppendJoin(ref this ZaSpanStringBuilder builder, ReadOnlySpan separator, ReadOnlySpan values, ReadOnlySpan format = default, IFormatProvider? provider = null) + where T : ISpanFormattable + { + for (var i = 0; i < values.Length; i++) { - if (value.Length > builder.RemainingSpan.Length) + if (i > 0) { - return false; + builder.Append(separator); } - value.CopyTo(builder.RemainingSpan); - builder.Advance(value.Length); - return true; + builder.Append(values[i], format, provider); } - /// - /// Attempts to append a string to the builder without throwing. - /// - /// The builder instance. - /// The string to append. If null, this is a no-op and returns true. - /// true if the value was appended; otherwise false if there was not enough capacity. - public static bool TryAppend(ref this ZaSpanStringBuilder builder, string? value) + return ref builder; + } + /// + /// Attempts to append a read-only span of characters to the builder without throwing. + /// + /// The builder instance. + /// The span of characters to append. + /// true if the value was appended; otherwise false if there was not enough capacity. + public static bool TryAppend(ref this ZaSpanStringBuilder builder, ReadOnlySpan value) + { + if (value.Length > builder.RemainingSpan.Length) { - return value is null || builder.TryAppend(value.AsSpan()); + return false; } - /// - /// Attempts to append a single character to the builder without throwing. - /// - /// The builder instance. - /// The character to append. - /// true if the value was appended; otherwise false if there was not enough capacity. - public static bool TryAppend(ref this ZaSpanStringBuilder builder, char value) - { - if (builder.RemainingSpan.Length < 1) - { - return false; - } + value.CopyTo(builder.RemainingSpan); + builder.Advance(value.Length); + return true; + } - builder.RemainingSpan[0] = value; - builder.Advance(1); - return true; - } + /// + /// Attempts to append a string to the builder without throwing. + /// + /// The builder instance. + /// The string to append. If null, this is a no-op and returns true. + /// true if the value was appended; otherwise false if there was not enough capacity. + public static bool TryAppend(ref this ZaSpanStringBuilder builder, string? value) + { + return value is null || builder.TryAppend(value.AsSpan()); + } - /// - /// Attempts to append a value of a type that implements without throwing. - /// - /// The type of the value, which must implement ISpanFormattable. - /// The builder instance. - /// The value to format and append. - /// An optional format string for the value. - /// Format provider to use. If null, is used. - /// true if the value was formatted and appended; otherwise false if there was not enough capacity or formatting failed. - public static bool TryAppend(ref this ZaSpanStringBuilder builder, T value, ReadOnlySpan format = default, IFormatProvider? provider = null) where T : ISpanFormattable + /// + /// Attempts to append a single character to the builder without throwing. + /// + /// The builder instance. + /// The character to append. + /// true if the value was appended; otherwise false if there was not enough capacity. + public static bool TryAppend(ref this ZaSpanStringBuilder builder, char value) + { + if (builder.RemainingSpan.Length < 1) { - provider ??= CultureInfo.InvariantCulture; + return false; + } - if (!value.TryFormat(builder.RemainingSpan, out var charsWritten, format, provider)) - { - return false; - } + builder.RemainingSpan[0] = value; + builder.Advance(1); + return true; + } - builder.Advance(charsWritten); - return true; - } + /// + /// Attempts to append a value of a type that implements without throwing. + /// + /// The type of the value, which must implement ISpanFormattable. + /// The builder instance. + /// The value to format and append. + /// An optional format string for the value. + /// Format provider to use. If null, is used. + /// + /// true if the value was formatted and appended; otherwise false if there was not enough capacity + /// or formatting failed. + /// + public static bool TryAppend(ref this ZaSpanStringBuilder builder, T value, ReadOnlySpan format = default, IFormatProvider? provider = null) where T : ISpanFormattable + { + provider ??= CultureInfo.InvariantCulture; - /// - /// Attempts to append the default line terminator to the builder without throwing. - /// - /// The builder instance. - /// true if the newline was appended; otherwise false if there was not enough capacity. - public static bool TryAppendLine(ref this ZaSpanStringBuilder builder) + if (!value.TryFormat(builder.RemainingSpan, out var charsWritten, format, provider)) { - var newline = Environment.NewLine.AsSpan(); - return builder.TryAppend(newline); + return false; } - /// - /// Attempts to append a string followed by the default line terminator to the builder without throwing. - /// The operation is atomic with respect to capacity: if there is not enough space for both, nothing is written. - /// - /// The builder instance. - /// The string to append. If null, only the newline is appended. - /// true if the string and newline were appended; otherwise false if there was not enough capacity. - public static bool TryAppendLine(ref this ZaSpanStringBuilder builder, string? value) - { - var valueLength = value?.Length ?? 0; - var newlineLength = Environment.NewLine.Length; - var required = valueLength + newlineLength; + builder.Advance(charsWritten); + return true; + } - if (required > builder.RemainingSpan.Length) - { - return false; - } + /// + /// Attempts to append the default line terminator to the builder without throwing. + /// + /// The builder instance. + /// true if the newline was appended; otherwise false if there was not enough capacity. + public static bool TryAppendLine(ref this ZaSpanStringBuilder builder) + { + var newline = Environment.NewLine.AsSpan(); + return builder.TryAppend(newline); + } - if (valueLength > 0) - { - value!.AsSpan().CopyTo(builder.RemainingSpan); - builder.Advance(valueLength); - } + /// + /// Attempts to append a string followed by the default line terminator to the builder without throwing. + /// The operation is atomic with respect to capacity: if there is not enough space for both, nothing is written. + /// + /// The builder instance. + /// The string to append. If null, only the newline is appended. + /// true if the string and newline were appended; otherwise false if there was not enough capacity. + public static bool TryAppendLine(ref this ZaSpanStringBuilder builder, string? value) + { + var valueLength = value?.Length ?? 0; + var newlineLength = Environment.NewLine.Length; + var required = valueLength + newlineLength; - Environment.NewLine.AsSpan().CopyTo(builder.RemainingSpan); - builder.Advance(newlineLength); - return true; + if (required > builder.RemainingSpan.Length) + { + return false; + } + + if (valueLength > 0) + { + value!.AsSpan().CopyTo(builder.RemainingSpan); + builder.Advance(valueLength); } + + Environment.NewLine.AsSpan().CopyTo(builder.RemainingSpan); + builder.Advance(newlineLength); + return true; + } /// /// Appends a read-only span of characters to the builder. /// @@ -409,7 +406,7 @@ public static ref ZaSpanStringBuilder Append(ref this ZaSpanStringBuilder bui /// The builder instance. /// The value to format and append. /// An optional format string for the value. - /// Format provider to use. If null, is used. + /// Format provider to use. If null, is used. /// A reference to the builder to allow for method chaining. /// Thrown if the buffer is too small to hold the formatted value. /// Thrown if the value cannot be formatted correctly. @@ -448,6 +445,7 @@ public static ref ZaSpanStringBuilder AppendLine(ref this ZaSpanStringBuilder bu { builder.Append(value); } + return ref builder.AppendLine(); } @@ -476,6 +474,7 @@ public static ref ZaSpanStringBuilder AppendIf(ref this ZaSpanStringBuilder buil { builder.Append(value); } + return ref builder; } @@ -492,6 +491,7 @@ public static ref ZaSpanStringBuilder AppendIf(ref this ZaSpanStringBuilder buil { builder.Append(value); } + return ref builder; } @@ -508,6 +508,7 @@ public static ref ZaSpanStringBuilder AppendIf(ref this ZaSpanStringBuilder buil { builder.Append(value); } + return ref builder; } @@ -526,6 +527,7 @@ public static ref ZaSpanStringBuilder AppendIf(ref this ZaSpanStringBuilder b { builder.Append(value, format); } + return ref builder; } @@ -537,372 +539,453 @@ private static void ThrowOutOfRangeException() throw new ArgumentOutOfRangeException("value", "The destination buffer is too small."); } - // Escaping helpers + // Escaping helpers - public static ref ZaSpanStringBuilder AppendJsonEscaped(ref this ZaSpanStringBuilder builder, ReadOnlySpan value) + public static ref ZaSpanStringBuilder AppendJsonEscaped(ref this ZaSpanStringBuilder builder, ReadOnlySpan value) + { + if (!TryAppendJsonEscaped(ref builder, value)) { - if (!TryAppendJsonEscaped(ref builder, value)) - { - ThrowOutOfRangeException(); - } - return ref builder; + ThrowOutOfRangeException(); } - public static bool TryAppendJsonEscaped(ref this ZaSpanStringBuilder builder, ReadOnlySpan value) + return ref builder; + } + + public static bool TryAppendJsonEscaped(ref this ZaSpanStringBuilder builder, ReadOnlySpan value) + { + var required = GetJsonEscapedLength(value); + if (required > builder.RemainingSpan.Length) { - var required = GetJsonEscapedLength(value); - if (required > builder.RemainingSpan.Length) - { - return false; - } - if (required == value.Length) - { - value.CopyTo(builder.RemainingSpan); - builder.Advance(value.Length); - return true; - } - 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; - 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); + return false; + } + + if (required == value.Length) + { + value.CopyTo(builder.RemainingSpan); + builder.Advance(value.Length); return true; } - private static int GetJsonEscapedLength(ReadOnlySpan value) + var dest = builder.RemainingSpan; + var w = 0; + for (var i = 0; i < value.Length; i++) { - var extra = 0; - for (var i = 0; i < value.Length; i++) + var c = value[i]; + switch (c) { - var c = value[i]; - switch (c) - { - case '"': - case '\\': - case '\b': - case '\f': - case '\n': - case '\r': - case '\t': - extra += 1; // becomes two chars instead of one - break; - default: - if (c < ' ') - { - extra += 5; // \u00XX adds 5 extra over the original 1 - } - break; - } + 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; + + 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); + 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; // becomes two chars instead of one + break; + + default: + if (c < ' ') + { + extra += 5; // \u00XX adds 5 extra over the original 1 + } + + break; } - return value.Length + extra; } - private static void WriteHexByte(byte b, Span dest) + return value.Length + extra; + } + + private static void WriteHexByte(byte b, Span dest) + { + const string hex = "0123456789ABCDEF"; + dest[0] = hex[b >> 4 & 0xF]; + dest[1] = hex[b & 0xF]; + } + + public static ref ZaSpanStringBuilder AppendHtmlEscaped(ref this ZaSpanStringBuilder builder, ReadOnlySpan value) + { + if (!TryAppendHtmlEscaped(ref builder, value)) { - const string hex = "0123456789ABCDEF"; - dest[0] = hex[(b >> 4) & 0xF]; - dest[1] = hex[b & 0xF]; + ThrowOutOfRangeException(); } - public static ref ZaSpanStringBuilder AppendHtmlEscaped(ref this ZaSpanStringBuilder builder, ReadOnlySpan value) + return ref builder; + } + + public static bool TryAppendHtmlEscaped(ref this ZaSpanStringBuilder builder, ReadOnlySpan value) + { + var required = GetHtmlEscapedLength(value); + if (required > builder.RemainingSpan.Length) { - if (!TryAppendHtmlEscaped(ref builder, value)) - { - ThrowOutOfRangeException(); - } - return ref builder; + return false; } - public static bool TryAppendHtmlEscaped(ref this ZaSpanStringBuilder builder, ReadOnlySpan value) + if (required == value.Length) { - var required = GetHtmlEscapedLength(value); - if (required > builder.RemainingSpan.Length) - { - return false; - } - if (required == value.Length) - { - value.CopyTo(builder.RemainingSpan); - builder.Advance(value.Length); - return true; - } - var dest = builder.RemainingSpan; - var w = 0; - for (var i = 0; i < value.Length; i++) - { - switch (value[i]) - { - 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++] = value[i]; break; - } - } - builder.Advance(required); + value.CopyTo(builder.RemainingSpan); + builder.Advance(value.Length); return true; } - private static int GetHtmlEscapedLength(ReadOnlySpan value) + 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); + return true; + } + + private static int GetHtmlEscapedLength(ReadOnlySpan value) + { + var extra = 0; + foreach (var t in value) { - var extra = 0; - for (var i = 0; i < value.Length; i++) + switch (t) { - switch (value[i]) - { - case '&': extra += 4; break; // & (5) - 1 original = +4 - case '<': - case '>': extra += 3; break; // < or > (4) -1 = +3 - case '"': extra += 5; break; // " (6) -1 = +5 - case '\'': extra += 4; break; // ' (5) -1 = +4 - } + case '&': extra += 4; break; // & (5) - 1 original = +4 + case '<': + case '>': extra += 3; break; // < or > (4) -1 = +3 + case '"': extra += 5; break; // " (6) -1 = +5 + case '\'': extra += 4; break; // ' (5) -1 = +4 } - return value.Length + extra; } - public static ref ZaSpanStringBuilder AppendCsvEscaped(ref this ZaSpanStringBuilder builder, ReadOnlySpan value) + return value.Length + extra; + } + + public static ref ZaSpanStringBuilder AppendCsvEscaped(ref this ZaSpanStringBuilder builder, ReadOnlySpan value) + { + if (!TryAppendCsvEscaped(ref builder, value)) { - if (!TryAppendCsvEscaped(ref builder, value)) - { - ThrowOutOfRangeException(); - } - return ref builder; + ThrowOutOfRangeException(); } - public static bool TryAppendCsvEscaped(ref this ZaSpanStringBuilder builder, ReadOnlySpan value) + return ref builder; + } + + public static bool TryAppendCsvEscaped(ref this ZaSpanStringBuilder builder, ReadOnlySpan value) + { + var needsQuote = NeedsCsvQuoting(value); + if (!needsQuote) { - var needsQuote = NeedsCsvQuoting(value); - if (!needsQuote) - { - return builder.TryAppend(value); - } - var quoteCount = 0; - for (var i = 0; i < value.Length; i++) if (value[i] == '"') quoteCount++; - var required = value.Length + quoteCount + 2; - if (required > builder.RemainingSpan.Length) - { - return false; - } - var dest = builder.RemainingSpan; - var w = 0; - dest[w++] = '"'; - for (var i = 0; i < value.Length; i++) - { - var c = value[i]; - dest[w++] = c; - if (c == '"') - { - dest[w++] = '"'; - } - } - dest[w++] = '"'; - builder.Advance(required); - return true; + return builder.TryAppend(value); + } + + var quoteCount = 0; + foreach (var t in value) + if (t == '"') + quoteCount++; + + var required = value.Length + quoteCount + 2; + if (required > builder.RemainingSpan.Length) + { + return false; } - private static bool NeedsCsvQuoting(ReadOnlySpan value) + var dest = builder.RemainingSpan; + var w = 0; + dest[w++] = '"'; + foreach (var c in value) { - if (value.Length == 0) return false; - if (char.IsWhiteSpace(value[0]) || char.IsWhiteSpace(value[^1])) return true; - for (var i = 0; i < value.Length; i++) + dest[w++] = c; + if (c == '"') { - var c = value[i]; - if (c == ',' || c == '"' || c == '\n' || c == '\r') return true; + dest[w++] = '"'; } - return false; } - // URL encoding and composition helpers + dest[w] = '"'; + builder.Advance(required); + return true; + } - private static bool IsUnreservedAscii(char c) + private static bool NeedsCsvQuoting(ReadOnlySpan value) + { + if (value.Length == 0) return false; + if (char.IsWhiteSpace(value[0]) || char.IsWhiteSpace(value[^1])) return true; + foreach (var c in value) { - return (c >= 'A' && c <= 'Z') || - (c >= 'a' && c <= 'z') || - (c >= '0' && c <= '9') || - c is '-' or '_' or '.' or '~'; + if (c is ',' or '"' or '\n' or '\r') return true; } - public static ref ZaSpanStringBuilder AppendUrlEncoded(ref this ZaSpanStringBuilder builder, ReadOnlySpan value) + return false; + } + + // URL encoding and composition helpers + + private static bool IsUnreservedAscii(char c) + { + return c is >= 'A' and <= 'Z' or >= 'a' and <= 'z' or < '0' or > '9' or '-' or '_' or '.' or '~'; + } + + public static ref ZaSpanStringBuilder AppendUrlEncoded(ref this ZaSpanStringBuilder builder, ReadOnlySpan value) + { + if (!TryAppendUrlEncoded(ref builder, value)) { - if (!TryAppendUrlEncoded(ref builder, value)) - { - ThrowOutOfRangeException(); - } - return ref builder; + ThrowOutOfRangeException(); } - public static bool TryAppendUrlEncoded(ref this ZaSpanStringBuilder builder, ReadOnlySpan value) + return ref builder; + } + + public static bool TryAppendUrlEncoded(ref this ZaSpanStringBuilder builder, ReadOnlySpan value) + { + var required = GetUrlEncodedLength(value); + if (required > builder.RemainingSpan.Length) { - var required = GetUrlEncodedLength(value); - if (required > builder.RemainingSpan.Length) - { - return false; - } + return false; + } - var dest = builder.RemainingSpan; - var w = 0; - for (int i = 0; i < value.Length; i++) + var dest = builder.RemainingSpan; + var w = 0; + for (var i = 0; i < value.Length; i++) + { + var c = value[i]; + if (c <= 0x7F) { - var c = value[i]; - if (c <= 0x7F) + if (IsUnreservedAscii(c)) { - 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 high = c; - var low = value[++i]; - var codePoint = 0x10000 + (((high - 0xD800) << 10) | (low - 0xDC00)); - w += PercentEncodeUtf8FromCodePoint(codePoint, dest.Slice(w)); + dest[w++] = c; } else { - var codePoint = (int)c; - w += PercentEncodeUtf8FromCodePoint(codePoint, dest.Slice(w)); + dest[w++] = '%'; + WriteHexByte((byte)c, dest.Slice(w, 2)); + w += 2; } } - - builder.Advance(required); - return true; + else if (char.IsHighSurrogate(c) && i + 1 < value.Length && char.IsLowSurrogate(value[i + 1])) + { + var high = c; + var low = value[++i]; + var codePoint = 0x10000 + (high - 0xD800 << 10 | low - 0xDC00); + w += PercentEncodeUtf8FromCodePoint(codePoint, dest.Slice(w)); + } + else + { + var codePoint = (int)c; + w += PercentEncodeUtf8FromCodePoint(codePoint, dest.Slice(w)); + } } - private static int GetUrlEncodedLength(ReadOnlySpan value) + builder.Advance(required); + return true; + } + + private static int GetUrlEncodedLength(ReadOnlySpan value) + { + var length = 0; + for (var i = 0; i < value.Length; i++) { - var length = 0; - for (int i = 0; i < value.Length; i++) + var c = value[i]; + if (c <= 0x7F) { - 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; // 4 UTF-8 bytes -> %HH %HH %HH %HH - i++; // consume low surrogate - } - else - { - // Non-surrogate BMP char: 0x80..0x7FF => 2 bytes; 0x800..0xFFFF => 3 bytes - length += (c <= 0x7FF) ? 2 * 3 : 3 * 3; - } + length += IsUnreservedAscii(c) ? 1 : 3; + } + else if (char.IsHighSurrogate(c) && i + 1 < value.Length && char.IsLowSurrogate(value[i + 1])) + { + length += 4 * 3; // 4 UTF-8 bytes -> %HH %HH %HH %HH + i++; // consume low surrogate + } + else + { + // Non-surrogate BMP char: 0x80..0x7FF => 2 bytes; 0x800..0xFFFF => 3 bytes + length += c <= 0x7FF ? 2 * 3 : 3 * 3; } - return length; } - private static int PercentEncodeUtf8FromCodePoint(int codePoint, Span dest) + return length; + } + + private static int PercentEncodeUtf8FromCodePoint(int codePoint, Span dest) + { + switch (codePoint) { // Returns number of chars written to dest (multiple of 3) - if (codePoint <= 0x7F) - { + case <= 0x7F: dest[0] = '%'; WriteHexByte((byte)codePoint, dest.Slice(1, 2)); return 3; - } - if (codePoint <= 0x7FF) + case <= 0x7FF: { - var b1 = (byte)(0b1100_0000 | (codePoint >> 6)); - var b2 = (byte)(0b1000_0000 | (codePoint & 0b0011_1111)); - dest[0] = '%'; WriteHexByte(b1, dest.Slice(1, 2)); - dest[3] = '%'; WriteHexByte(b2, dest.Slice(4, 2)); + var b1 = (byte)(0b1100_0000 | codePoint >> 6); + var b2 = (byte)(0b1000_0000 | codePoint & 0b0011_1111); + dest[0] = '%'; + WriteHexByte(b1, dest.Slice(1, 2)); + dest[3] = '%'; + WriteHexByte(b2, dest.Slice(4, 2)); return 6; } - if (codePoint <= 0xFFFF) + + 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)); - dest[0] = '%'; WriteHexByte(b1, dest.Slice(1, 2)); - dest[3] = '%'; WriteHexByte(b2, dest.Slice(4, 2)); - dest[6] = '%'; WriteHexByte(b3, dest.Slice(7, 2)); + var b1 = (byte)(0b1110_0000 | codePoint >> 12); + var b2 = (byte)(0b1000_0000 | codePoint >> 6 & 0b0011_1111); + var b3 = (byte)(0b1000_0000 | codePoint & 0b0011_1111); + dest[0] = '%'; + WriteHexByte(b1, dest.Slice(1, 2)); + dest[3] = '%'; + WriteHexByte(b2, dest.Slice(4, 2)); + dest[6] = '%'; + WriteHexByte(b3, dest.Slice(7, 2)); return 9; } - else + + 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)); - dest[0] = '%'; WriteHexByte(b1, dest.Slice(1, 2)); - dest[3] = '%'; WriteHexByte(b2, dest.Slice(4, 2)); - dest[6] = '%'; WriteHexByte(b3, dest.Slice(7, 2)); - dest[9] = '%'; WriteHexByte(b4, dest.Slice(10, 2)); + 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); + dest[0] = '%'; + WriteHexByte(b1, dest.Slice(1, 2)); + dest[3] = '%'; + WriteHexByte(b2, dest.Slice(4, 2)); + dest[6] = '%'; + WriteHexByte(b3, dest.Slice(7, 2)); + dest[9] = '%'; + WriteHexByte(b4, dest.Slice(10, 2)); return 12; } } + } - public static ref ZaSpanStringBuilder AppendPathSegment(ref this ZaSpanStringBuilder builder, ReadOnlySpan segment, char separator = '/') + public static ref ZaSpanStringBuilder AppendPathSegment(ref this ZaSpanStringBuilder builder, ReadOnlySpan segment, char separator = '/') + { + if (builder.Length > 0 && builder[^1] != separator) { - if (builder.Length > 0 && builder[builder.Length - 1] != separator) - { - builder.Append(separator); - } - - // Trim leading separators in segment - int start = 0; - while (start < segment.Length && segment[start] == separator) start++; - if (start < segment.Length) - { - builder.Append(segment[start..]); - } - return ref builder; + builder.Append(separator); } - public static ref ZaSpanStringBuilder AppendQueryParam(ref this ZaSpanStringBuilder builder, ReadOnlySpan key, ReadOnlySpan value, bool urlEncode = true, bool isFirst = false) + // Trim leading separators in segment + var start = 0; + while (start < segment.Length && segment[start] == separator) start++; + if (start < segment.Length) { - builder.Append(isFirst ? '?' : '&'); - if (urlEncode) - { - builder.AppendUrlEncoded(key); - builder.Append('='); - builder.AppendUrlEncoded(value); - } - else - { - builder.Append(key); - builder.Append('='); - builder.Append(value); - } + builder.Append(segment[start..]); + } - return ref builder; + return ref builder; + } + + public static ref ZaSpanStringBuilder AppendQueryParam(ref this ZaSpanStringBuilder builder, ReadOnlySpan key, ReadOnlySpan value, bool urlEncode = true, bool isFirst = false) + { + builder.Append(isFirst ? '?' : '&'); + if (urlEncode) + { + builder.AppendUrlEncoded(key); + builder.Append('='); + builder.AppendUrlEncoded(value); } + else + { + builder.Append(key); + builder.Append('='); + builder.Append(value); + } + + return ref builder; + } public static ref ZaSpanStringBuilder AppendFormat(ref this ZaSpanStringBuilder builder, string format, params object?[] args) { diff --git a/src/ZaString/Extensions/ZaUtf8SpanWriterExtensions.cs b/src/ZaString/Extensions/ZaUtf8SpanWriterExtensions.cs index b0c344e..a8ad280 100644 --- a/src/ZaString/Extensions/ZaUtf8SpanWriterExtensions.cs +++ b/src/ZaString/Extensions/ZaUtf8SpanWriterExtensions.cs @@ -1,13 +1,12 @@ using System.Buffers; -using System.Globalization; -using System.Text; using System.Buffers.Text; +using System.Text; using ZaString.Core; namespace ZaString.Extensions; /// -/// Provides extension methods for the . +/// Provides extension methods for the . /// public static class ZaUtf8SpanWriterExtensions { @@ -42,13 +41,19 @@ public static ref ZaUtf8SpanWriter Append(ref this ZaUtf8SpanWriter writer, stri public static ref ZaUtf8SpanWriter Append(ref this ZaUtf8SpanWriter writer, char value) { - var bytes = Encoding.UTF8.GetByteCount(stackalloc char[1] { value }); + var bytes = Encoding.UTF8.GetByteCount(stackalloc char[1] + { + value + }); if (bytes > writer.RemainingSpan.Length) { ThrowOutOfRangeException(); } - var written = Encoding.UTF8.GetBytes(stackalloc char[1] { value }, writer.RemainingSpan); + var written = Encoding.UTF8.GetBytes(stackalloc char[1] + { + value + }, writer.RemainingSpan); writer.Advance(written); return ref writer; } @@ -112,6 +117,7 @@ public static ref ZaUtf8SpanWriter AppendLine(ref this ZaUtf8SpanWriter writer, { writer.Append(value); } + return ref writer.AppendLine(); } @@ -130,7 +136,7 @@ public static ref ZaUtf8SpanWriter AppendHex(ref this ZaUtf8SpanWriter writer, R for (var i = 0; i < data.Length; i++) { var b = data[i]; - dest[w++] = (byte)hex[(b >> 4) & 0xF]; + dest[w++] = (byte)hex[b >> 4 & 0xF]; dest[w++] = (byte)hex[b & 0xF]; } @@ -160,4 +166,4 @@ private static void ThrowOutOfRangeException() { throw new ArgumentOutOfRangeException("value", "The destination buffer is too small."); } -} \ No newline at end of file +} \ No newline at end of file diff --git a/tests/ZaString.Tests/ZaPooledStringBuilderTests.cs b/tests/ZaString.Tests/ZaPooledStringBuilderTests.cs index 1bfbf8c..7e7d81e 100644 --- a/tests/ZaString.Tests/ZaPooledStringBuilderTests.cs +++ b/tests/ZaString.Tests/ZaPooledStringBuilderTests.cs @@ -7,7 +7,7 @@ public class ZaPooledStringBuilderTests [Fact] public void Append_GrowsAndBuilds() { - using var b = ZaPooledStringBuilder.Rent(initialCapacity: 4); + using var b = ZaPooledStringBuilder.Rent(4); b.Append("Hello").Append(", ").Append("World!"); Assert.Equal("Hello, World!", b.AsSpan()); Assert.Equal("Hello, World!", b.ToString()); @@ -30,5 +30,4 @@ public void Clear_ResetsLength() Assert.Equal(0, b.Length); Assert.Equal("", b.AsSpan()); } -} - +} \ No newline at end of file diff --git a/tests/ZaString.Tests/ZaSpanStringBuilderAppendHelpersTests.cs b/tests/ZaString.Tests/ZaSpanStringBuilderAppendHelpersTests.cs index b2fc737..01a3cbf 100644 --- a/tests/ZaString.Tests/ZaSpanStringBuilderAppendHelpersTests.cs +++ b/tests/ZaString.Tests/ZaSpanStringBuilderAppendHelpersTests.cs @@ -73,10 +73,13 @@ public void AppendJoin_ISpanFormattable_Works_WithProvider() Span buffer = stackalloc char[20]; var builder = ZaSpanStringBuilder.Create(buffer); - var values = new[] { 1.5, 2.5 }; + var values = new[] + { + 1.5, + 2.5 + }; builder.AppendJoin("; ".AsSpan(), values, provider: fr); Assert.Equal("1,5; 2,5", builder.AsSpan()); } -} - +} \ No newline at end of file diff --git a/tests/ZaString.Tests/ZaSpanStringBuilderBasicTests.cs b/tests/ZaString.Tests/ZaSpanStringBuilderBasicTests.cs index ced6d6e..25bd8ed 100644 --- a/tests/ZaString.Tests/ZaSpanStringBuilderBasicTests.cs +++ b/tests/ZaString.Tests/ZaSpanStringBuilderBasicTests.cs @@ -607,7 +607,7 @@ public void Clear_PreservesCapacity() builder.Append("Test"); var originalCapacity = builder.Capacity; - + builder.Clear(); Assert.Equal(originalCapacity, builder.Capacity); diff --git a/tests/ZaString.Tests/ZaSpanStringBuilderEdgeCasesTests.cs b/tests/ZaString.Tests/ZaSpanStringBuilderEdgeCasesTests.cs index a8db740..6168323 100644 --- a/tests/ZaString.Tests/ZaSpanStringBuilderEdgeCasesTests.cs +++ b/tests/ZaString.Tests/ZaSpanStringBuilderEdgeCasesTests.cs @@ -233,9 +233,9 @@ public void Clear_AfterMultipleOperations_ResetsCorrectly() builder.Append("Hello").Append(' ').Append(42).Append(true); var lengthBeforeClear = builder.Length; - + builder.Clear(); - + Assert.True(lengthBeforeClear > 0); Assert.Equal(0, builder.Length); Assert.Equal("", builder.AsSpan()); diff --git a/tests/ZaString.Tests/ZaSpanStringBuilderEscapingTests.cs b/tests/ZaString.Tests/ZaSpanStringBuilderEscapingTests.cs index 42c4aa0..7843240 100644 --- a/tests/ZaString.Tests/ZaSpanStringBuilderEscapingTests.cs +++ b/tests/ZaString.Tests/ZaSpanStringBuilderEscapingTests.cs @@ -48,5 +48,4 @@ public void TryAppendCsvEscaped_Quotes_Commas_Newlines() Assert.True(ok); Assert.Equal("\" a,\"\"b\"\"\n\"", builder.AsSpan()); } -} - +} \ No newline at end of file diff --git a/tests/ZaString.Tests/ZaSpanStringBuilderFormatTests.cs b/tests/ZaString.Tests/ZaSpanStringBuilderFormatTests.cs index a2dfa2b..480a100 100644 --- a/tests/ZaString.Tests/ZaSpanStringBuilderFormatTests.cs +++ b/tests/ZaString.Tests/ZaSpanStringBuilderFormatTests.cs @@ -54,4 +54,4 @@ public void AppendFormat_WithISpanFormattable_UsesSpanFormattable() var expected = string.Format("Int: {0:N0}, Double: {1:F2}", 123, 456.789); Assert.Equal(expected, builder.AsSpan().ToString()); } -} \ No newline at end of file +} \ No newline at end of file diff --git a/tests/ZaString.Tests/ZaSpanStringBuilderInterpolationTests.cs b/tests/ZaString.Tests/ZaSpanStringBuilderInterpolationTests.cs index 3cd9339..ce52fe5 100644 --- a/tests/ZaString.Tests/ZaSpanStringBuilderInterpolationTests.cs +++ b/tests/ZaString.Tests/ZaSpanStringBuilderInterpolationTests.cs @@ -43,5 +43,4 @@ public void AppendLine_InterpolatedString_AppendsNewline() Assert.Equal($"Line: 42{Environment.NewLine}", builder.AsSpan()); } -} - +} \ No newline at end of file diff --git a/tests/ZaString.Tests/ZaSpanStringBuilderMutationHelpersTests.cs b/tests/ZaString.Tests/ZaSpanStringBuilderMutationHelpersTests.cs index 51a5b0d..2cdd879 100644 --- a/tests/ZaString.Tests/ZaSpanStringBuilderMutationHelpersTests.cs +++ b/tests/ZaString.Tests/ZaSpanStringBuilderMutationHelpersTests.cs @@ -55,5 +55,4 @@ public void EnsureEndsWith_NoOpWhenAlreadyEnding() Assert.Equal("abcx", builder.AsSpan()); } -} - +} \ No newline at end of file diff --git a/tests/ZaString.Tests/ZaSpanStringBuilderTryAppendTests.cs b/tests/ZaString.Tests/ZaSpanStringBuilderTryAppendTests.cs index abaaab0..26421ad 100644 --- a/tests/ZaString.Tests/ZaSpanStringBuilderTryAppendTests.cs +++ b/tests/ZaString.Tests/ZaSpanStringBuilderTryAppendTests.cs @@ -38,7 +38,7 @@ public void TryAppend_String_Null_ReturnsTrue_NoChange() Span buffer = stackalloc char[3]; var builder = ZaSpanStringBuilder.Create(buffer); - var ok = builder.TryAppend((string?)null); + var ok = builder.TryAppend(null); Assert.True(ok); Assert.Equal(0, builder.Length); @@ -137,7 +137,7 @@ public void TryAppendLine_OnlyNewline_InsufficientCapacity_ReturnsFalse_NoChange { var newlineLen = Environment.NewLine.Length; var capacity = Math.Max(0, newlineLen - 1); - Span buffer = capacity == 0 ? Span.Empty : stackalloc char[capacity]; + var buffer = capacity == 0 ? Span.Empty : stackalloc char[capacity]; var builder = ZaSpanStringBuilder.Create(buffer); var ok = builder.TryAppendLine(); @@ -181,5 +181,4 @@ public void TryAppendLine_String_Succeeds_WhenCapacitySufficient() Assert.Equal(value + Environment.NewLine, builder.AsSpan()); Assert.Equal(required, builder.Length); } -} - +} \ No newline at end of file diff --git a/tests/ZaString.Tests/ZaSpanStringBuilderUrlHelpersTests.cs b/tests/ZaString.Tests/ZaSpanStringBuilderUrlHelpersTests.cs index 8eb9e03..e636cb0 100644 --- a/tests/ZaString.Tests/ZaSpanStringBuilderUrlHelpersTests.cs +++ b/tests/ZaString.Tests/ZaSpanStringBuilderUrlHelpersTests.cs @@ -46,10 +46,9 @@ public void AppendQueryParam_Encodes_And_Uses_Correct_Delimiters() var builder = ZaSpanStringBuilder.Create(buffer); builder.Append("/search") - .AppendQueryParam("q", "a b", urlEncode: true, isFirst: true) - .AppendQueryParam("page", "1", urlEncode: false); + .AppendQueryParam("q", "a b", true, true) + .AppendQueryParam("page", "1", false); Assert.Equal("/search?q=a%20b&page=1", builder.AsSpan()); } -} - +} \ No newline at end of file diff --git a/tests/ZaString.Tests/ZaUtf8SpanWriterTests.cs b/tests/ZaString.Tests/ZaUtf8SpanWriterTests.cs index 93d889d..528e90b 100644 --- a/tests/ZaString.Tests/ZaUtf8SpanWriterTests.cs +++ b/tests/ZaString.Tests/ZaUtf8SpanWriterTests.cs @@ -60,8 +60,13 @@ public void AppendHex_FormatsBytesAsHex() Span buffer = stackalloc byte[16]; var writer = ZaUtf8SpanWriter.Create(buffer); - var data = new byte[] { 0xAB, 0xCD, 0xEF }; - writer.AppendHex(data, uppercase: true); + var data = new byte[] + { + 0xAB, + 0xCD, + 0xEF + }; + writer.AppendHex(data, true); var expected = Encoding.UTF8.GetBytes("ABCDEF"); Assert.True(writer.AsSpan().SequenceEqual(expected)); @@ -73,11 +78,16 @@ public void AppendBase64_EncodesBytes() Span buffer = stackalloc byte[32]; var writer = ZaUtf8SpanWriter.Create(buffer); - var data = new byte[] { 0x01, 0x02, 0x03 }; + var data = new byte[] + { + 0x01, + 0x02, + 0x03 + }; writer.AppendBase64(data); var expected = Convert.ToBase64String(data); var expectedBytes = Encoding.UTF8.GetBytes(expected); Assert.True(writer.AsSpan().SequenceEqual(expectedBytes)); } -} \ No newline at end of file +} \ No newline at end of file From 418e47ab449d8bf61fb22202e0a954d61e5983a5 Mon Sep 17 00:00:00 2001 From: Corentin Giaufer Saubert <43623834+CorentinGS@users.noreply.github.com> Date: Thu, 7 Aug 2025 23:28:42 +0200 Subject: [PATCH 12/13] fix: Correct logical error in IsUnreservedAscii method to properly include numeric characters in ZaSpanStringBuilderExtensions --- src/ZaString/Extensions/ZaSpanStringBuilderExtensions.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ZaString/Extensions/ZaSpanStringBuilderExtensions.cs b/src/ZaString/Extensions/ZaSpanStringBuilderExtensions.cs index d967a72..c5b155a 100644 --- a/src/ZaString/Extensions/ZaSpanStringBuilderExtensions.cs +++ b/src/ZaString/Extensions/ZaSpanStringBuilderExtensions.cs @@ -815,7 +815,7 @@ private static bool NeedsCsvQuoting(ReadOnlySpan value) private static bool IsUnreservedAscii(char c) { - return c is >= 'A' and <= 'Z' or >= 'a' and <= 'z' or < '0' or > '9' or '-' or '_' or '.' or '~'; + return c is >= 'A' and <= 'Z' or >= 'a' and <= 'z' or >= '0' and <= '9' or '-' or '_' or '.' or '~'; } public static ref ZaSpanStringBuilder AppendUrlEncoded(ref this ZaSpanStringBuilder builder, ReadOnlySpan value) From 694774febba63a6a30e0f77e7bcd4640aa7992e8 Mon Sep 17 00:00:00 2001 From: Corentin Giaufer Saubert <43623834+CorentinGS@users.noreply.github.com> Date: Thu, 7 Aug 2025 23:29:53 +0200 Subject: [PATCH 13/13] fix: Simplify code point calculation in ZaSpanStringBuilderExtensions to improve clarity and maintainability --- src/ZaString/Extensions/ZaSpanStringBuilderExtensions.cs | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/ZaString/Extensions/ZaSpanStringBuilderExtensions.cs b/src/ZaString/Extensions/ZaSpanStringBuilderExtensions.cs index c5b155a..4c1a724 100644 --- a/src/ZaString/Extensions/ZaSpanStringBuilderExtensions.cs +++ b/src/ZaString/Extensions/ZaSpanStringBuilderExtensions.cs @@ -856,15 +856,14 @@ public static bool TryAppendUrlEncoded(ref this ZaSpanStringBuilder builder, Rea } else if (char.IsHighSurrogate(c) && i + 1 < value.Length && char.IsLowSurrogate(value[i + 1])) { - var high = c; var low = value[++i]; - var codePoint = 0x10000 + (high - 0xD800 << 10 | low - 0xDC00); - w += PercentEncodeUtf8FromCodePoint(codePoint, dest.Slice(w)); + var codePoint = 0x10000 + (c - 0xD800 << 10 | low - 0xDC00); + w += PercentEncodeUtf8FromCodePoint(codePoint, dest[w..]); } else { var codePoint = (int)c; - w += PercentEncodeUtf8FromCodePoint(codePoint, dest.Slice(w)); + w += PercentEncodeUtf8FromCodePoint(codePoint, dest[w..]); } }