diff --git a/samples/ZaString.Demo/Program.cs b/samples/ZaString.Demo/Program.cs index 60ec05c..6e18e22 100644 --- a/samples/ZaString.Demo/Program.cs +++ b/samples/ZaString.Demo/Program.cs @@ -1,4 +1,5 @@ using System.Diagnostics; +using System.Globalization; using System.Text; using ZaString.Core; using ZaString.Extensions; @@ -37,9 +38,167 @@ public static void Main() CharacterModificationDemo(); Console.WriteLine(); + TryAppendDemo(); + Console.WriteLine(); + + AppendHelpersDemo(); + Console.WriteLine(); + + InterpolationDemo(); + Console.WriteLine(); + + JsonEscapingDemo(); + Console.WriteLine(); + + UrlHelpersDemo(); + Console.WriteLine(); + + PooledBuilderDemo(); + Console.WriteLine(); + + Utf8WriterDemo(); + Console.WriteLine(); + + FormatDemo(); + 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[] + { + 1.5, + 2.5, + 3.5 + }; + builder.AppendJoin("; ".AsSpan(), values, provider: new CultureInfo("fr-FR")); + + 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 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 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 PooledBuilderDemo() + { + Console.WriteLine("--- Pooled Builder ---"); + + using var b = ZaPooledStringBuilder.Rent(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 ---"); @@ -296,4 +455,69 @@ 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, true); + 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/Core/ZaPooledStringBuilder.cs b/src/ZaString/Core/ZaPooledStringBuilder.cs new file mode 100644 index 0000000..7f873ab --- /dev/null +++ b/src/ZaString/Core/ZaPooledStringBuilder.cs @@ -0,0 +1,138 @@ +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 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; + } + + public int Length { get; private set; } + + public int Capacity + { + get => _buffer.Length; + } + + public void Dispose() + { + if (_disposed) return; + _disposed = true; + var buf = _buffer; + _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) + { + 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 *= 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(); + } +} \ No newline at end of file diff --git a/src/ZaString/Core/ZaSpanString.cs b/src/ZaString/Core/ZaSpanString.cs index c3ece7d..ffeb077 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/Core/ZaUtf8SpanWriter.cs b/src/ZaString/Core/ZaUtf8SpanWriter.cs new file mode 100644 index 0000000..54f56f8 --- /dev/null +++ b/src/ZaString/Core/ZaUtf8SpanWriter.cs @@ -0,0 +1,62 @@ +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. +/// +public ref struct ZaUtf8SpanWriter +{ + private readonly Span _buffer; + public int Length { get; private set; } + + private int Capacity + { + get => _buffer.Length; + } + + public ReadOnlySpan WrittenSpan + { + get => _buffer[..Length]; + } + + public Span RemainingSpan + { + get => _buffer[Length..]; + } + + private ZaUtf8SpanWriter(Span buffer) + { + _buffer = buffer; + Length = 0; + } + + public static ZaUtf8SpanWriter Create(Span buffer) + { + return new ZaUtf8SpanWriter(buffer); + } + + 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; + } + + public void Clear() + { + Length = 0; + } + + public ReadOnlySpan AsSpan() + { + return WrittenSpan; + } + + 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 new file mode 100644 index 0000000..d233422 --- /dev/null +++ b/src/ZaString/Extensions/ZaInterpolatedStringHandler.cs @@ -0,0 +1,59 @@ +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() + { + return _builder; + } +} \ No newline at end of file diff --git a/src/ZaString/Extensions/ZaSpanStringBuilderExtensions.cs b/src/ZaString/Extensions/ZaSpanStringBuilderExtensions.cs index 2dac637..4c1a724 100644 --- a/src/ZaString/Extensions/ZaSpanStringBuilderExtensions.cs +++ b/src/ZaString/Extensions/ZaSpanStringBuilderExtensions.cs @@ -1,3 +1,5 @@ +using System.Globalization; +using System.Runtime.CompilerServices; using ZaString.Core; namespace ZaString.Extensions; @@ -7,6 +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) + { + 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) + { + 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[^1] != value) + { + builder.Append(value); + } + + 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); + + 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) + { + ArgumentOutOfRangeException.ThrowIfNegative(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. + /// + /// 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. /// @@ -85,10 +396,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(); } @@ -118,6 +445,7 @@ public static ref ZaSpanStringBuilder AppendLine(ref this ZaSpanStringBuilder bu { builder.Append(value); } + return ref builder.AppendLine(); } @@ -146,6 +474,7 @@ public static ref ZaSpanStringBuilder AppendIf(ref this ZaSpanStringBuilder buil { builder.Append(value); } + return ref builder; } @@ -162,6 +491,7 @@ public static ref ZaSpanStringBuilder AppendIf(ref this ZaSpanStringBuilder buil { builder.Append(value); } + return ref builder; } @@ -178,6 +508,7 @@ public static ref ZaSpanStringBuilder AppendIf(ref this ZaSpanStringBuilder buil { builder.Append(value); } + return ref builder; } @@ -196,6 +527,7 @@ public static ref ZaSpanStringBuilder AppendIf(ref this ZaSpanStringBuilder b { builder.Append(value, format); } + return ref builder; } @@ -206,4 +538,462 @@ 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; + 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) + { + 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; + 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) + { + switch (t) + { + 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; + foreach (var t in value) + if (t == '"') + quoteCount++; + + var required = value.Length + quoteCount + 2; + if (required > builder.RemainingSpan.Length) + { + return false; + } + + var dest = builder.RemainingSpan; + var w = 0; + dest[w++] = '"'; + foreach (var c in value) + { + dest[w++] = c; + if (c == '"') + { + dest[w++] = '"'; + } + } + + dest[w] = '"'; + builder.Advance(required); + 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; + foreach (var c in value) + { + if (c is ',' or '"' or '\n' or '\r') return true; + } + + return false; + } + + // URL encoding and composition helpers + + private static bool IsUnreservedAscii(char c) + { + 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) + { + 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 (var i = 0; i < value.Length; i++) + { + var c = value[i]; + if (c <= 0x7F) + { + if (IsUnreservedAscii(c)) + { + dest[w++] = c; + } + else + { + dest[w++] = '%'; + WriteHexByte((byte)c, dest.Slice(w, 2)); + w += 2; + } + } + else if (char.IsHighSurrogate(c) && i + 1 < value.Length && char.IsLowSurrogate(value[i + 1])) + { + var low = value[++i]; + var codePoint = 0x10000 + (c - 0xD800 << 10 | low - 0xDC00); + w += PercentEncodeUtf8FromCodePoint(codePoint, dest[w..]); + } + else + { + var codePoint = (int)c; + w += PercentEncodeUtf8FromCodePoint(codePoint, dest[w..]); + } + } + + builder.Advance(required); + return true; + } + + private static int GetUrlEncodedLength(ReadOnlySpan value) + { + var length = 0; + for (var i = 0; i < value.Length; i++) + { + var c = value[i]; + if (c <= 0x7F) + { + length += IsUnreservedAscii(c) ? 1 : 3; + } + else if (char.IsHighSurrogate(c) && i + 1 < value.Length && char.IsLowSurrogate(value[i + 1])) + { + length += 4 * 3; // 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) + { + switch (codePoint) + { + // Returns number of chars written to dest (multiple of 3) + case <= 0x7F: + dest[0] = '%'; + WriteHexByte((byte)codePoint, dest.Slice(1, 2)); + return 3; + 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)); + return 6; + } + + case <= 0xFFFF: + { + var b1 = (byte)(0b1110_0000 | codePoint >> 12); + var b2 = (byte)(0b1000_0000 | codePoint >> 6 & 0b0011_1111); + var b3 = (byte)(0b1000_0000 | codePoint & 0b0011_1111); + 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; + } + + 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)); + return 12; + } + } + } + + public static ref ZaSpanStringBuilder AppendPathSegment(ref this ZaSpanStringBuilder builder, ReadOnlySpan segment, char separator = '/') + { + if (builder.Length > 0 && builder[^1] != separator) + { + builder.Append(separator); + } + + // Trim leading separators in segment + var 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; + } + + 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/src/ZaString/Extensions/ZaUtf8SpanWriterExtensions.cs b/src/ZaString/Extensions/ZaUtf8SpanWriterExtensions.cs new file mode 100644 index 0000000..a8ad280 --- /dev/null +++ b/src/ZaString/Extensions/ZaUtf8SpanWriterExtensions.cs @@ -0,0 +1,169 @@ +using System.Buffers; +using System.Buffers.Text; +using System.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/ZaPooledStringBuilderTests.cs b/tests/ZaString.Tests/ZaPooledStringBuilderTests.cs new file mode 100644 index 0000000..7e7d81e --- /dev/null +++ b/tests/ZaString.Tests/ZaPooledStringBuilderTests.cs @@ -0,0 +1,33 @@ +using ZaString.Core; + +namespace ZaString.Tests; + +public class ZaPooledStringBuilderTests +{ + [Fact] + public void Append_GrowsAndBuilds() + { + using var b = ZaPooledStringBuilder.Rent(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()); + } +} \ No newline at end of file diff --git a/tests/ZaString.Tests/ZaSpanStringBuilderAppendHelpersTests.cs b/tests/ZaString.Tests/ZaSpanStringBuilderAppendHelpersTests.cs new file mode 100644 index 0000000..01a3cbf --- /dev/null +++ b/tests/ZaString.Tests/ZaSpanStringBuilderAppendHelpersTests.cs @@ -0,0 +1,85 @@ +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()); + } +} \ 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 new file mode 100644 index 0000000..7843240 --- /dev/null +++ b/tests/ZaString.Tests/ZaSpanStringBuilderEscapingTests.cs @@ -0,0 +1,51 @@ +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()); + } +} \ 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..480a100 --- /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 diff --git a/tests/ZaString.Tests/ZaSpanStringBuilderInterpolationTests.cs b/tests/ZaString.Tests/ZaSpanStringBuilderInterpolationTests.cs new file mode 100644 index 0000000..ce52fe5 --- /dev/null +++ b/tests/ZaString.Tests/ZaSpanStringBuilderInterpolationTests.cs @@ -0,0 +1,46 @@ +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()); + } +} \ No newline at end of file diff --git a/tests/ZaString.Tests/ZaSpanStringBuilderMutationHelpersTests.cs b/tests/ZaString.Tests/ZaSpanStringBuilderMutationHelpersTests.cs new file mode 100644 index 0000000..2cdd879 --- /dev/null +++ b/tests/ZaString.Tests/ZaSpanStringBuilderMutationHelpersTests.cs @@ -0,0 +1,58 @@ +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()); + } +} \ No newline at end of file diff --git a/tests/ZaString.Tests/ZaSpanStringBuilderTryAppendTests.cs b/tests/ZaString.Tests/ZaSpanStringBuilderTryAppendTests.cs new file mode 100644 index 0000000..26421ad --- /dev/null +++ b/tests/ZaString.Tests/ZaSpanStringBuilderTryAppendTests.cs @@ -0,0 +1,184 @@ +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(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); + var 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); + } +} \ 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..e636cb0 --- /dev/null +++ b/tests/ZaString.Tests/ZaSpanStringBuilderUrlHelpersTests.cs @@ -0,0 +1,54 @@ +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", 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 new file mode 100644 index 0000000..528e90b --- /dev/null +++ b/tests/ZaString.Tests/ZaUtf8SpanWriterTests.cs @@ -0,0 +1,93 @@ +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, 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