diff --git a/.gitignore b/.gitignore index 43fc899..74871b9 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,5 @@ riderModule.iml artifacts .github/instructions build_log.txt +.opencode/ +.worktrees/ diff --git a/README.md b/README.md index a68efb6..f28b288 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,7 @@ Key highlights: - **UTF‑8 support**: Write bytes directly when you need to emit UTF‑8 - **Escape helpers**: Ready-to-use JSON, HTML, CSV, URL escaping utilities - **Interpolated string handlers**: Integrates with C# interpolation for zero-cost formatting +- **Stack and pooled workflows**: Choose `ZaSpanStringBuilder` for fixed buffers or `ZaPooledStringBuilder` for dynamically growing text ## 📦 Installation @@ -85,6 +86,10 @@ using var builder = ZaPooledStringBuilder.Rent(128); builder.Append("Pooled string building") .Append(" with automatic cleanup"); +builder[0] = 'p'; +builder.RemoveLast(7); +builder.Append("cleanup and edits"); + var result = builder.ToString(); // Buffer automatically returned to pool when disposed ``` @@ -114,6 +119,7 @@ var age = 30; var pi = Math.PI; builder.Append($"User: {name}, Age: {age}, Pi: {pi:F2}"); +builder.Append($" | {age,4} | {pi,8:F2} |"); // alignment is supported ``` ### Number Formatting @@ -161,14 +167,57 @@ builder.AppendCsvEscaped("Value,with,commas"); ### URL Building ```csharp +var isFirst = true; + builder.AppendPathSegment("api") .AppendPathSegment("v1") .AppendPathSegment("users") - .AppendQueryParam("q", "search term", isFirst: true) - .AppendQueryParam("page", "1"); + .AppendQueryParam("q", "search term", ref isFirst) + .AppendQueryParam("page", "1", ref isFirst); // Result: "api/v1/users?q=search%20term&page=1" ``` +### Form URL Encoding vs URL Encoding + +```csharp +Span buffer = stackalloc char[128]; +var builder = ZaSpanStringBuilder.Create(buffer); + +builder.AppendUrlEncoded("Ada Lovelace"); // Ada%20Lovelace +builder.Clear(); +builder.AppendFormUrlEncoded("Ada Lovelace"); // Ada+Lovelace +``` + +Use `AppendUrlEncoded()` for URL components and `AppendFormUrlEncoded()` for +`application/x-www-form-urlencoded` payloads. + +### Mutation Helpers + +```csharp +Span buffer = stackalloc char[64]; +var builder = ZaSpanStringBuilder.Create(buffer); + +builder.Append("Status: pending"); +builder.SetLength(8); // keep "Status: " +builder.Append("ready"); +builder.RemoveLast(1); +builder.Append('!'); + +Console.WriteLine(builder.AsSpan()); // Status: ready! +``` + +The pooled builder exposes the same mutation helpers plus `TryAppend()` +overloads for `char`, `string`, and `ReadOnlySpan`. + +### Span-Based Composite Formatting + +```csharp +ReadOnlySpan template = "User: {0}, Total: {1:C}"; +builder.AppendFormat(CultureInfo.InvariantCulture, template, "Alice", 42.5); +``` + +This is useful when the format template already exists as a span. + ### TryAppend Methods Non-throwing variants for buffer overflow handling: @@ -318,7 +367,7 @@ var result = builder.AsSpan(); // Zero allocation ### Disposal & Pooling - **ZaPooledStringBuilder**: Always use `using` or call `Dispose()` to return the internal buffer to the `ArrayPool`. Failure to do so will lead to memory leaks in the pool. -- **ZaUtf8Handle**: This struct wraps a pooled array. You **MUST** call `Dispose()` when finished. Avoid copying this struct around, as multiple disposals of the same handle can corrupt the pool. +- **ZaUtf8Handle**: This `ref struct` wraps a pooled array. You **MUST** call `Dispose()` when finished. Keep it local and short-lived; the `ref struct` shape prevents the unsafe copy/dispose patterns that a regular struct would allow. ### Unsafe Pointers diff --git a/docs/superpowers/plans/2026-04-25-review-fixes.md b/docs/superpowers/plans/2026-04-25-review-fixes.md new file mode 100644 index 0000000..a3499b0 --- /dev/null +++ b/docs/superpowers/plans/2026-04-25-review-fixes.md @@ -0,0 +1,454 @@ +# Review Fixes Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Fix the approved PR review issues, add regression coverage, and push the branch safely. + +**Architecture:** Keep the current API surface and file layout intact. Apply targeted fixes in the affected builder, interpolation, and encoding helpers, then add focused tests that prove the reviewed failure modes are resolved. + +**Tech Stack:** C#, .NET 10 test execution in this environment, xUnit, Jujutsu (`jj`) + +--- + +### Task 1: Fix Span Builder Mutation And Query Atomicity + +**Files:** +- Modify: `src/ZaString/Extensions/ZaSpanStringBuilderExtensions.cs` +- Test: `tests/ZaString.Tests/ZaSpanStringBuilderMutationHelpersTests.cs` +- Test: `tests/ZaString.Tests/ZaSpanStringBuilderUrlHelpersTests.cs` + +- [ ] **Step 1: Write the failing tests** + +Add these tests. + +In `tests/ZaString.Tests/ZaSpanStringBuilderMutationHelpersTests.cs`: + +```csharp +[Fact] +public void RemoveLast_Extension_RemovesCorrectly() +{ + Span buffer = stackalloc char[16]; + var builder = ZaSpanStringBuilder.Create(buffer); + builder.Append("abcdef"); + + ZaSpanStringBuilderExtensions.RemoveLast(ref builder, 2); + + Assert.Equal("abcd", builder.AsSpan()); + Assert.Equal(4, builder.Length); +} +``` + +In `tests/ZaString.Tests/ZaSpanStringBuilderUrlHelpersTests.cs`: + +```csharp +[Fact] +public void AppendQueryParam_WithRefBool_Failure_IsAtomic() +{ + Span buffer = stackalloc char[8]; + var builder = ZaSpanStringBuilder.Create(buffer); + builder.Append("/s"); + + var isFirst = true; + + Assert.Throws(() => + builder.AppendQueryParam("longkey", "x", ref isFirst)); + + Assert.Equal("/s", builder.AsSpan()); + Assert.True(isFirst); +} +``` + +- [ ] **Step 2: Run the targeted tests to verify they fail** + +Run: `dotnet test tests/ZaString.Tests/ZaString.Tests.csproj -f net10.0 --filter "FullyQualifiedName~RemoveLast_Extension_RemovesCorrectly|FullyQualifiedName~AppendQueryParam_WithRefBool_Failure_IsAtomic"` + +Expected: FAIL with `RemoveLast` throwing and/or the query helper leaving partial state. + +- [ ] **Step 3: Write the minimal implementation** + +Update `src/ZaString/Extensions/ZaSpanStringBuilderExtensions.cs`. + +Replace the `RemoveLast` body with truncation via `SetLength`: + +```csharp +public static ref ZaSpanStringBuilder RemoveLast(ref this ZaSpanStringBuilder builder, int count) +{ + ArgumentOutOfRangeException.ThrowIfNegative(count); + if (count == 0) + { + return ref builder; + } + + if (builder.Length < count) + { + ThrowOutOfRangeException(); + } + + builder.SetLength(builder.Length - count); + return ref builder; +} +``` + +Make `AppendQueryParam` atomic by precomputing required length and mutating `isFirst` only after success. Add shared helpers near the query helper section: + +```csharp +private static int GetQueryParamLength(ReadOnlySpan key, ReadOnlySpan value, bool urlEncode) +{ + var keyLength = urlEncode ? GetUrlEncodedLengthReplacingInvalid(value: key) : key.Length; + var valueLength = urlEncode ? GetUrlEncodedLengthReplacingInvalid(value) : value.Length; + return 1 + keyLength + 1 + valueLength; +} + +private static void WriteQueryParam(Span destination, char prefix, ReadOnlySpan key, ReadOnlySpan value, bool urlEncode) +{ + destination[0] = prefix; + var written = 1; + + if (urlEncode) + { + written += WriteUrlEncodedReplacingInvalid(key, destination[written..]); + destination[written++] = '='; + written += WriteUrlEncodedReplacingInvalid(value, destination[written..]); + return; + } + + key.CopyTo(destination[written..]); + written += key.Length; + destination[written++] = '='; + value.CopyTo(destination[written..]); +} +``` + +Then rewrite both overloads to use those helpers: + +```csharp +public static ref ZaSpanStringBuilder AppendQueryParam(ref this ZaSpanStringBuilder builder, ReadOnlySpan key, ReadOnlySpan value, bool urlEncode = true, bool isFirst = false) +{ + var required = GetQueryParamLength(key, value, urlEncode); + if (required > builder.RemainingSpan.Length) + { + ThrowOutOfRangeException(); + } + + WriteQueryParam(builder.RemainingSpan, isFirst ? '?' : '&', key, value, urlEncode); + builder.Advance(required); + return ref builder; +} + +public static ref ZaSpanStringBuilder AppendQueryParam(ref this ZaSpanStringBuilder builder, ReadOnlySpan key, ReadOnlySpan value, ref bool isFirst, bool urlEncode = true) +{ + var required = GetQueryParamLength(key, value, urlEncode); + if (required > builder.RemainingSpan.Length) + { + ThrowOutOfRangeException(); + } + + WriteQueryParam(builder.RemainingSpan, isFirst ? '?' : '&', key, value, urlEncode); + builder.Advance(required); + isFirst = false; + return ref builder; +} +``` + +- [ ] **Step 4: Run the targeted tests to verify they pass** + +Run: `dotnet test tests/ZaString.Tests/ZaString.Tests.csproj -f net10.0 --filter "FullyQualifiedName~RemoveLast_Extension_RemovesCorrectly|FullyQualifiedName~AppendQueryParam_WithRefBool_Failure_IsAtomic"` + +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +jj desc -m "Fix builder mutation and query atomicity" +jj new -m "Add interpolation and encoding review fixes" +jj st +``` + +### Task 2: Complete Alignment Support And Remove Artificial Retry Limit + +**Files:** +- Modify: `src/ZaString/Extensions/ZaInterpolatedStringHandler.cs` +- Modify: `src/ZaString/Core/ZaPooledStringBuilder.cs` +- Test: `tests/ZaString.Tests/ZaSpanStringBuilderInterpolationTests.cs` +- Test: `tests/ZaString.Tests/ZaPooledStringBuilderTests.cs` + +- [ ] **Step 1: Write the failing tests** + +In `tests/ZaString.Tests/ZaSpanStringBuilderInterpolationTests.cs`, add: + +```csharp +[Fact] +public void Append_InterpolatedString_WithAlignedString_Works() +{ + Span buffer = stackalloc char[32]; + var builder = ZaSpanStringBuilder.Create(buffer); + + var name = "Ada"; + builder.Append($"|{name,6}|{name,-6}|"); + + Assert.Equal("| Ada|Ada |", builder.AsSpan()); +} +``` + +In `tests/ZaString.Tests/ZaPooledStringBuilderTests.cs`, add a formattable that succeeds only once capacity reaches a large threshold and verify append succeeds: + +```csharp +public readonly struct LargeFormattable : ISpanFormattable +{ + public bool TryFormat(Span destination, out int charsWritten, ReadOnlySpan format, IFormatProvider? provider) + { + if (destination.Length < 2_000_000) + { + charsWritten = 0; + return false; + } + + "big".AsSpan().CopyTo(destination); + charsWritten = 3; + return true; + } + + public string ToString(string? format, IFormatProvider? formatProvider) => "big"; +} + +[Fact] +public void Append_LargeISpanFormattable_GrowsUntilItSucceeds() +{ + using var builder = ZaPooledStringBuilder.Rent(4); + + builder.Append(new LargeFormattable()); + + Assert.Equal("big", builder.ToString()); +} +``` + +- [ ] **Step 2: Run the targeted tests to verify they fail** + +Run: `dotnet test tests/ZaString.Tests/ZaString.Tests.csproj -f net10.0 --filter "FullyQualifiedName~Append_InterpolatedString_WithAlignedString_Works|FullyQualifiedName~Append_LargeISpanFormattable_GrowsUntilItSucceeds"` + +Expected: FAIL because aligned string interpolation is unsupported and the pooled append hits the retry limit. + +- [ ] **Step 3: Write the minimal implementation** + +In `src/ZaString/Extensions/ZaInterpolatedStringHandler.cs`, add aligned overloads that share a small helper: + +```csharp +public void AppendFormatted(string? value, int alignment) +{ + AppendAligned(value is null ? ReadOnlySpan.Empty : value.AsSpan(), alignment); +} + +public void AppendFormatted(string? value, int alignment, string? format) +{ + AppendAligned(value is null ? ReadOnlySpan.Empty : value.AsSpan(), alignment); +} + +public void AppendFormatted(ReadOnlySpan value, int alignment) +{ + AppendAligned(value, alignment); +} + +public void AppendFormatted(ReadOnlySpan value, int alignment, string? format) +{ + AppendAligned(value, alignment); +} + +private void AppendAligned(ReadOnlySpan value, int alignment) +{ + var width = Math.Abs(alignment); + var totalWidth = Math.Max(width, value.Length); + var remaining = _builder.RemainingSpan; + + if (remaining.Length < totalWidth) + { + throw new ArgumentOutOfRangeException("value", "The destination buffer is too small."); + } + + var padCount = totalWidth - value.Length; + if (alignment > 0) + { + remaining[..padCount].Fill(' '); + value.CopyTo(remaining[padCount..]); + } + else + { + value.CopyTo(remaining); + remaining.Slice(value.Length, padCount).Fill(' '); + } + + _builder.Advance(totalWidth); +} +``` + +In `src/ZaString/Core/ZaPooledStringBuilder.cs`, remove the retry counter and keep the growth loop bounded only by `EnsureCapacity()`: + +```csharp +public ZaPooledStringBuilder Append(T value, ReadOnlySpan format = default, IFormatProvider? provider = null) where T : ISpanFormattable +{ + ThrowIfDisposed(); + provider ??= CultureInfo.InvariantCulture; + + while (true) + { + if (value.TryFormat(_buffer.AsSpan(Length), out var written, format, provider)) + { + Length += written; + return this; + } + + var remaining = _buffer.Length - Length; + var growBy = remaining + 1; + EnsureCapacity(growBy); + } +} +``` + +- [ ] **Step 4: Run the targeted tests to verify they pass** + +Run: `dotnet test tests/ZaString.Tests/ZaString.Tests.csproj -f net10.0 --filter "FullyQualifiedName~Append_InterpolatedString_WithAlignedString_Works|FullyQualifiedName~Append_LargeISpanFormattable_GrowsUntilItSucceeds"` + +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +jj desc -m "Add interpolation and encoding review fixes" +jj new -m "Harden review edge cases and tests" +jj st +``` + +### Task 3: Harden Edge Cases, Verify, And Push + +**Files:** +- Modify: `src/ZaString/Extensions/ZaSpanStringBuilderExtensions.cs` +- Modify: `src/ZaString/Core/ZaPooledStringBuilder.cs` +- Test: `tests/ZaString.Tests/ZaSpanStringBuilderTryAppendTests.cs` +- Test: `tests/ZaString.Tests/ZaSpanStringBuilderUrlHelpersTests.cs` +- Test: `tests/ZaString.Tests/ZaPooledStringBuilderTests.cs` + +- [ ] **Step 1: Write the failing tests** + +Add these tests. + +In `tests/ZaString.Tests/ZaSpanStringBuilderUrlHelpersTests.cs`: + +```csharp +[Fact] +public void AppendFormUrlEncoded_LoneHighSurrogate_UsesReplacementCharacter() +{ + Span buffer = stackalloc char[32]; + var builder = ZaSpanStringBuilder.Create(buffer); + + builder.AppendFormUrlEncoded("\uD800"); + + Assert.Equal("%EF%BF%BD", builder.AsSpan()); +} +``` + +In `tests/ZaString.Tests/ZaSpanStringBuilderTryAppendTests.cs`, add coverage for a subtraction-based helper that replaces the overflow-prone `required = valueLength + newlineLength` arithmetic: + +```csharp +[Theory] +[InlineData(5, 2, 7, true)] +[InlineData(5, 2, 6, false)] +[InlineData(int.MaxValue, 2, int.MaxValue, false)] +public void HasCapacityForLine_UsesOverflowSafeArithmetic(int valueLength, int newlineLength, int remainingLength, bool expected) +{ + var hasCapacityForLine = typeof(ZaSpanStringBuilderExtensions) + .GetMethod("HasCapacityForLine", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static); + + Assert.NotNull(hasCapacityForLine); + var actual = (bool)hasCapacityForLine.Invoke(null, new object[] { valueLength, newlineLength, remainingLength })!; + Assert.Equal(expected, actual); +} +``` + +In `tests/ZaString.Tests/ZaPooledStringBuilderTests.cs`, add a clamp-oriented reflection test for the growth helper introduced in this task: + +```csharp +[Theory] +[InlineData(200, 300, 300)] +[InlineData(200, 100, 300)] +[InlineData(0, 1, 256)] +public void ComputeExpandedCapacity_ClampsAndGrowsSafely(int currentCapacity, int required, int expected) +{ + var computeExpandedCapacity = typeof(ZaPooledStringBuilder) + .GetMethod("ComputeExpandedCapacity", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static); + + Assert.NotNull(computeExpandedCapacity); + var actual = (int)computeExpandedCapacity.Invoke(null, new object[] { currentCapacity, required })!; + Assert.Equal(expected, actual); +} +``` + +- [ ] **Step 2: Run the targeted tests to verify they fail** + +Run: `dotnet test tests/ZaString.Tests/ZaString.Tests.csproj -f net10.0 --filter "FullyQualifiedName~AppendFormUrlEncoded_LoneHighSurrogate_UsesReplacementCharacter"` + +Run: `dotnet test tests/ZaString.Tests/ZaString.Tests.csproj -f net10.0 --filter "FullyQualifiedName~HasCapacityForLine_UsesOverflowSafeArithmetic|FullyQualifiedName~ComputeExpandedCapacity_ClampsAndGrowsSafely"` + +Expected: FAIL with the current invalid surrogate encoding behavior and missing helper methods. + +- [ ] **Step 3: Write the minimal implementation** + +In `src/ZaString/Extensions/ZaSpanStringBuilderExtensions.cs`: + +- Add an internal helper that avoids addition overflow and call it from `TryAppendLine(string?)`. +- Refactor URL/form encoding length and write paths to treat lone surrogates as U+FFFD and reuse the same helper for query parameter size computation. + +Use helpers like: + +```csharp +private static bool HasCapacityForLine(int valueLength, int newlineLength, int remainingLength) +{ + return valueLength <= remainingLength && newlineLength <= remainingLength - valueLength; +} + +private static int GetUrlEncodedLengthReplacingInvalid(ReadOnlySpan value) +private static int WriteUrlEncodedReplacingInvalid(ReadOnlySpan value, Span destination) +``` + +and in the lone-surrogate branch percent-encode UTF-8 bytes for `0xFFFD`. + +In `src/ZaString/Core/ZaPooledStringBuilder.cs`, clamp growth: + +```csharp +private static int ComputeExpandedCapacity(int currentCapacity, int required) +{ + var grown = currentCapacity <= Array.MaxLength - (currentCapacity / 2) + ? currentCapacity + (currentCapacity / 2) + : Array.MaxLength; + + var newCapacity = Math.Max(required, grown); + if (newCapacity < 256) + { + newCapacity = 256; + } + + return Math.Min(newCapacity, Array.MaxLength); +} +``` + +and update `EnsureCapacity` to call `ComputeExpandedCapacity(_buffer.Length, required)` before renting. + +- [ ] **Step 4: Run verification** + +Run: `dotnet test tests/ZaString.Tests/ZaString.Tests.csproj -f net10.0` + +Expected: PASS all tests on `net10.0`. + +Run: `jj diff` + +Expected: Only the approved review-fix files and tests are changed. + +- [ ] **Step 5: Push** + +```bash +jj desc -m "Harden review edge cases and tests" +jj bookmark list +jj bookmark move feature/zastring-final --to @ +jj git push -b feature/zastring-final +jj st +``` + +Expected: bookmark moves to the current change and the branch pushes successfully. diff --git a/docs/superpowers/specs/2026-04-25-review-fixes-design.md b/docs/superpowers/specs/2026-04-25-review-fixes-design.md new file mode 100644 index 0000000..288480e --- /dev/null +++ b/docs/superpowers/specs/2026-04-25-review-fixes-design.md @@ -0,0 +1,57 @@ +# Review Fixes Design + +## Goal + +Apply the approved PR review fixes with the smallest possible code change set, preserve the current public API where practical, and add regression tests for each reviewed behavior before pushing the branch. + +## Scope + +This change set covers: + +- Fixing `ZaSpanStringBuilderExtensions.RemoveLast()` after the `Advance()` validation change. +- Making query parameter helpers failure-atomic, including the `ref bool isFirst` overload. +- Completing interpolated alignment support for common `string` and `ReadOnlySpan` inputs. +- Removing the arbitrary retry cap from `ZaPooledStringBuilder.Append()`. +- Hardening reviewed edge cases in `TryAppendLine()`, pooled capacity growth, and URL/form encoding for lone surrogates. +- Adding direct regression tests for all of the above. + +This change set does not include unrelated refactoring or API redesign. + +## Design + +### Mutation helpers + +`ZaSpanStringBuilderExtensions.RemoveLast()` will stop calling `Advance(-count)` and instead truncate through `SetLength(builder.Length - count)`. This matches the current builder contract and keeps the extension behavior aligned with the struct instance method. + +### Query parameter atomicity + +`AppendQueryParam()` will be changed to compute the total required output length first, using the same encoding rules as the eventual write path. Only after the full operation is proven to fit will the method append delimiters and encoded content. The `ref bool isFirst` overload will update `isFirst` only after a successful append. + +### Interpolated alignment + +`ZaInterpolatedStringHandler` will gain aligned overloads for `string?` and `ReadOnlySpan`, with behavior matching standard composite-format alignment semantics. These overloads will preserve atomic failure semantics: if the aligned write cannot fit, they will throw without partially advancing the builder. + +### Pooled formatting growth + +`ZaPooledStringBuilder.Append()` will no longer fail after a fixed retry count. Instead, it will continue growing until formatting succeeds or capacity growth reaches a real limit enforced by `EnsureCapacity()`. + +### Edge-case hardening + +- `TryAppendLine(string?)` will use overflow-safe capacity checks. +- `ZaPooledStringBuilder.EnsureCapacity()` will clamp growth to `Array.MaxLength` while still honoring the required size. +- URL/form encoding helpers will treat lone surrogates consistently with UTF-8 replacement-character behavior instead of emitting invalid UTF-8 percent-encodings. + +## Testing + +Add focused regression tests for: + +- `RemoveLast()` truncation on the extension path. +- Failure-atomic `AppendQueryParam()` behavior and `isFirst` state preservation on failure. +- Aligned string and span interpolation. +- Large or repeatedly-growing `ISpanFormattable` formatting behavior without an artificial retry cap. +- `TryAppendLine()` overflow-safe atomic failure. +- Lone-surrogate URL/form encoding output. + +## Verification + +Run the relevant `ZaString.Tests` project tests on the available framework in this environment and review the resulting diff before pushing. If multi-target execution is blocked by missing runtimes, record that explicitly and verify on the installed target framework. diff --git a/samples/ZaString.Demo/Program.cs b/samples/ZaString.Demo/Program.cs index 6e18e22..07426f4 100644 --- a/samples/ZaString.Demo/Program.cs +++ b/samples/ZaString.Demo/Program.cs @@ -47,6 +47,9 @@ public static void Main() InterpolationDemo(); Console.WriteLine(); + BuilderMutationDemo(); + Console.WriteLine(); + JsonEscapingDemo(); Console.WriteLine(); @@ -139,6 +142,29 @@ private static void InterpolationDemo() builder.Clear(); builder.AppendLine($"Line: {42}"); Console.WriteLine(builder.AsSpan().ToString()); + + builder.Clear(); + builder.Append($"|{12,6}|{34,-6}|{Math.PI,8:F2}|"); + Console.WriteLine($"Aligned columns: {builder.AsSpan()}"); + } + + private static void BuilderMutationDemo() + { + Console.WriteLine("--- Builder Mutation Helpers ---"); + + Span buffer = stackalloc char[128]; + var builder = ZaSpanStringBuilder.Create(buffer); + + builder.Append("Status: pending"); + Console.WriteLine($"Before mutation: {builder.AsSpan()}"); + + builder.SetLength(8); // Keep "Status: " + builder.Append("ready"); + Console.WriteLine($"After SetLength: {builder.AsSpan()}"); + + builder.RemoveLast(1); + builder.Append('!'); + Console.WriteLine($"After RemoveLast + Append: {builder.AsSpan()}"); } private static void JsonEscapingDemo() @@ -182,11 +208,20 @@ private static void UrlHelpersDemo() var builder = ZaSpanStringBuilder.Create(buffer); // Path composition ensures single separators + var isFirst = true; builder.AppendPathSegment("api").AppendPathSegment("/v1/").AppendPathSegment("users"); - builder.AppendQueryParam("q", "a b", isFirst: true); - builder.AppendQueryParam("tag", "c#"); + builder.AppendQueryParam("q", "a b", ref isFirst); + builder.AppendQueryParam("tag", "c#", ref isFirst); Console.WriteLine(builder.AsSpan().ToString()); // api/v1/users?q=a%20b&tag=c%23 + + builder.Clear(); + builder.Append("Form body: name=") + .AppendFormUrlEncoded("Ada Lovelace") + .Append("&title=") + .AppendFormUrlEncoded("c# engineer"); + + Console.WriteLine(builder.AsSpan().ToString()); // spaces become '+' in form payloads } private static void PooledBuilderDemo() @@ -197,6 +232,17 @@ private static void PooledBuilderDemo() b.Append("Hello").Append(", ").Append("World!").Append(' ').Append(123); Console.WriteLine(b.AsSpan().ToString()); Console.WriteLine(b.ToString()); + + b.Clear(); + b.Append("draft"); + b[0] = 'D'; + b.TryAppend(" message"); + b.RemoveLast(4); + b.Append("note"); + Console.WriteLine($"Mutated pooled text: {b.AsSpan()}"); + + using var utf8 = b.ToUtf8NullTerminated(); + Console.WriteLine($"UTF-8 bytes (with null terminator): {utf8.Span.Length}"); } private static void BasicUsageDemo() @@ -441,18 +487,18 @@ private static void CharacterModificationDemo() { var invalidChar = builder[builder.Length]; // This should throw } - catch (IndexOutOfRangeException) + catch (ArgumentOutOfRangeException) { - Console.WriteLine("✓ IndexOutOfRangeException caught for index >= Length"); + Console.WriteLine("✓ ArgumentOutOfRangeException caught for index >= Length"); } try { builder[-1] = 'X'; // This should throw } - catch (IndexOutOfRangeException) + catch (ArgumentOutOfRangeException) { - Console.WriteLine("✓ IndexOutOfRangeException caught for negative index"); + Console.WriteLine("✓ ArgumentOutOfRangeException caught for negative index"); } } @@ -510,6 +556,12 @@ private static void FormatDemo() builder.AppendFormat(fr, "User: {0}, Age: {1}, Pi: {2:F2}", name, age, pi); Console.WriteLine(builder.AsSpan().ToString()); + // Span-based overload keeps the call-site flexible when the template is already a span. + builder.Clear(); + ReadOnlySpan spanTemplate = "Span template => User: {0}, Age: {1}"; + builder.AppendFormat(spanTemplate, name, age); + Console.WriteLine(builder.AsSpan().ToString()); + // Clear and demonstrate AppendFormat with custom formatting builder.Clear(); builder.AppendFormat(fr, "Currency: {0:C}", 1234.56); @@ -520,4 +572,4 @@ private static void FormatDemo() 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 index 15020d4..c4cbf39 100644 --- a/src/ZaString/Core/ZaPooledStringBuilder.cs +++ b/src/ZaString/Core/ZaPooledStringBuilder.cs @@ -15,6 +15,7 @@ public sealed class ZaPooledStringBuilder : IDisposable private ZaPooledStringBuilder(ArrayPool pool, int initialCapacity) { + ArgumentOutOfRangeException.ThrowIfNegative(initialCapacity); _pool = pool; _buffer = pool.Rent(Math.Max(1, initialCapacity)); Length = 0; @@ -24,7 +25,11 @@ private ZaPooledStringBuilder(ArrayPool pool, int initialCapacity) public int Capacity { - get => _buffer.Length; + get + { + ThrowIfDisposed(); + return _buffer.Length; + } } public void Dispose() @@ -36,6 +41,12 @@ public void Dispose() _pool.Return(buf); } + private void ThrowIfDisposed() + { + if (_disposed) + throw new ObjectDisposedException(nameof(ZaPooledStringBuilder)); + } + public static ZaPooledStringBuilder Rent(int initialCapacity = 256, ArrayPool? pool = null) { return new ZaPooledStringBuilder(pool ?? ArrayPool.Shared, initialCapacity); @@ -43,32 +54,129 @@ public static ZaPooledStringBuilder Rent(int initialCapacity = 256, ArrayPool AsSpan() { + ThrowIfDisposed(); return _buffer.AsSpan(0, Length); } public override string ToString() { + ThrowIfDisposed(); return new string(_buffer, 0, Length); } public void Clear() { + ThrowIfDisposed(); Length = 0; } + /// + /// Sets the length to the specified value. Only truncation is allowed. + /// + public void SetLength(int newLength) + { + ThrowIfDisposed(); + if ((uint)newLength > (uint)Length) + throw new ArgumentOutOfRangeException(nameof(newLength)); + + Length = newLength; + } + + /// + /// Removes the last characters. + /// + public void RemoveLast(int count) + { + ThrowIfDisposed(); + if ((uint)count > (uint)Length) + throw new ArgumentOutOfRangeException(nameof(count)); + + Length -= count; + } + + /// + /// Gets or sets the character at the specified index. + /// + public char this[int index] + { + get + { + ThrowIfDisposed(); + if ((uint)index >= (uint)Length) + throw new ArgumentOutOfRangeException(nameof(index)); + return _buffer[index]; + } + set + { + ThrowIfDisposed(); + if ((uint)index >= (uint)Length) + throw new ArgumentOutOfRangeException(nameof(index)); + _buffer[index] = value; + } + } + + /// + /// Attempts to append a read-only span without throwing. + /// + public bool TryAppend(ReadOnlySpan value) + { + ThrowIfDisposed(); + + if (value.Length > Array.MaxLength - Length) + { + return false; + } + + try + { + EnsureCapacity(value.Length); + } + catch (ArgumentOutOfRangeException) + { + return false; + } + + value.CopyTo(_buffer.AsSpan(Length)); + Length += value.Length; + return true; + } + + /// + /// Attempts to append a string without throwing. + /// + public bool TryAppend(string? value) + { + return value is null || TryAppend(value.AsSpan()); + } + + /// + /// Attempts to append a single character without throwing. + /// + public bool TryAppend(char value) + { + ThrowIfDisposed(); + + if (Length >= Array.MaxLength) + { + return false; + } + + EnsureCapacity(1); + _buffer[Length++] = value; + return true; + } + private void EnsureCapacity(int additionalRequired) { ArgumentOutOfRangeException.ThrowIfNegative(additionalRequired); + if (Length > Array.MaxLength - additionalRequired) + throw new ArgumentOutOfRangeException(nameof(additionalRequired), "Required capacity exceeds maximum array length."); + var required = Length + additionalRequired; if (required <= _buffer.Length) return; - var newCapacity = _buffer.Length; - if (newCapacity == 0) newCapacity = 1; - while (newCapacity < required) - { - newCapacity *= 2; - } + var newCapacity = ComputeExpandedCapacity(_buffer.Length, required); var newBuffer = _pool.Rent(newCapacity); _buffer.AsSpan(0, Length).CopyTo(newBuffer); @@ -76,8 +184,24 @@ private void EnsureCapacity(int additionalRequired) _buffer = newBuffer; } + private static int ComputeExpandedCapacity(int currentCapacity, int required) + { + var grown = currentCapacity <= Array.MaxLength - (currentCapacity / 2) + ? currentCapacity + (currentCapacity / 2) + : Array.MaxLength; + + var newCapacity = Math.Max(required, grown); + if (newCapacity < 256) + { + newCapacity = 256; + } + + return Math.Min(newCapacity, Array.MaxLength); + } + public ZaPooledStringBuilder Append(ReadOnlySpan value) { + ThrowIfDisposed(); EnsureCapacity(value.Length); value.CopyTo(_buffer.AsSpan(Length)); Length += value.Length; @@ -86,6 +210,7 @@ public ZaPooledStringBuilder Append(ReadOnlySpan value) public ZaPooledStringBuilder Append(string? value) { + ThrowIfDisposed(); if (!string.IsNullOrEmpty(value)) { Append(value.AsSpan()); @@ -96,6 +221,7 @@ public ZaPooledStringBuilder Append(string? value) public ZaPooledStringBuilder Append(char value) { + ThrowIfDisposed(); EnsureCapacity(1); _buffer[Length++] = value; return this; @@ -103,12 +229,15 @@ public ZaPooledStringBuilder Append(char value) public ZaPooledStringBuilder Append(bool value) { + ThrowIfDisposed(); return Append(value ? "true" : "false"); } public ZaPooledStringBuilder Append(T value, ReadOnlySpan format = default, IFormatProvider? provider = null) where T : ISpanFormattable { + ThrowIfDisposed(); provider ??= CultureInfo.InvariantCulture; + while (true) { if (value.TryFormat(_buffer.AsSpan(Length), out var written, format, provider)) @@ -117,20 +246,21 @@ public ZaPooledStringBuilder Append(T value, ReadOnlySpan format = defa return this; } - // Grow and retry: ensure growth beyond current capacity by at least one character var remaining = _buffer.Length - Length; - var growBy = remaining + 1; // force capacity to exceed current length + var growBy = remaining + 1; EnsureCapacity(growBy); } } public ZaPooledStringBuilder AppendLine() { + ThrowIfDisposed(); return Append(Environment.NewLine); } public ZaPooledStringBuilder AppendLine(string? value) { + ThrowIfDisposed(); if (value is not null) { Append(value); @@ -141,6 +271,7 @@ public ZaPooledStringBuilder AppendLine(string? value) public ZaUtf8Handle ToUtf8NullTerminated() { + ThrowIfDisposed(); var span = AsSpan(); var byteCount = Encoding.UTF8.GetByteCount(span); @@ -148,13 +279,14 @@ public ZaUtf8Handle ToUtf8NullTerminated() var byteBuffer = bytePool.Rent(byteCount + 1); Encoding.UTF8.TryGetBytes(span, byteBuffer, out var bytesWritten); - byteBuffer[bytesWritten] = 0; // Null terminate + byteBuffer[bytesWritten] = 0; return new ZaUtf8Handle(byteBuffer, bytesWritten + 1, bytePool); } public bool TryToUtf8NullTerminated(Span destination, out int bytesWritten) { + ThrowIfDisposed(); var span = AsSpan(); var byteCount = Encoding.UTF8.GetByteCount(span); var required = byteCount + 1; @@ -166,13 +298,19 @@ public bool TryToUtf8NullTerminated(Span destination, out int bytesWritten } Encoding.UTF8.TryGetBytes(span, destination, out bytesWritten); - destination[bytesWritten] = 0; // Null terminate - bytesWritten++; // Include null terminator in count + destination[bytesWritten] = 0; + bytesWritten++; return true; } public unsafe bool TryToUtf8NullTerminated(byte* buffer, int length, out int bytesWritten) { + ThrowIfDisposed(); + if (length < 0) + { + bytesWritten = 0; + return false; + } if (buffer == null) { bytesWritten = 0; @@ -181,4 +319,4 @@ public unsafe bool TryToUtf8NullTerminated(byte* buffer, int length, out int byt return TryToUtf8NullTerminated(new Span(buffer, length), out bytesWritten); } -} \ No newline at end of file +} diff --git a/src/ZaString/Core/ZaSpanString.cs b/src/ZaString/Core/ZaSpanString.cs index d033768..bb338fe 100644 --- a/src/ZaString/Core/ZaSpanString.cs +++ b/src/ZaString/Core/ZaSpanString.cs @@ -7,11 +7,9 @@ namespace ZaString.Core; /// -/// A zero-allocation string builder that writes directly to a provided Span -/// -/// . -/// This is a ref struct to ensure it is only allocated on the stack. -/// Append operations are provided as extension methods to allow for a fluent, chainable API. +/// A zero-allocation string builder that writes directly to a provided Span<char>. +/// This is a ref struct to ensure it is only allocated on the stack. +/// Append operations are provided as extension methods to allow for a fluent, chainable API. /// public ref struct ZaSpanStringBuilder { @@ -52,7 +50,7 @@ public readonly int Capacity /// /// The zero-based index of the character to access. /// A reference to the character at the specified index. - /// + /// /// Thrown when the index is negative or greater than or equal to the current /// length. /// @@ -61,7 +59,7 @@ public readonly ref char this[int index] get { if ((uint)index >= (uint)Length) - throw new IndexOutOfRangeException(); + throw new ArgumentOutOfRangeException(nameof(index)); return ref _buffer[index]; } } @@ -109,8 +107,8 @@ public static unsafe ZaSpanStringBuilder Create(char* ptr, int length) /// 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."); + if (count < 0 || count > Capacity - Length) + throw new ArgumentOutOfRangeException(nameof(count)); Length += count; } @@ -279,6 +277,12 @@ public readonly bool TryToUtf8NullTerminated(Span destination, out int byt public unsafe readonly bool TryToUtf8NullTerminated(byte* buffer, int length, out int bytesWritten) { + if (length < 0) + { + bytesWritten = 0; + return false; + } + if (buffer == null) { bytesWritten = 0; diff --git a/src/ZaString/Core/ZaUtf8Handle.cs b/src/ZaString/Core/ZaUtf8Handle.cs index fd2f348..14f9a63 100644 --- a/src/ZaString/Core/ZaUtf8Handle.cs +++ b/src/ZaString/Core/ZaUtf8Handle.cs @@ -9,7 +9,19 @@ namespace ZaString.Core; /// A disposable handle for a pooled UTF-8 byte buffer. /// This struct MUST be disposed to return the buffer to the pool. /// -public struct ZaUtf8Handle : IDisposable +/// +/// +/// is a ref struct, which means it is stack-only. +/// It cannot be boxed, stored in fields of reference types, captured by lambdas or local functions, +/// used across await or yield boundaries, or passed through generic APIs that require +/// non-stack storage. +/// +/// +/// Consumers should use this handle only for short-lived, synchronous operations and dispose it before +/// it goes out of scope. +/// +/// +public ref struct ZaUtf8Handle { private byte[]? _buffer; private readonly ArrayPool _pool; diff --git a/src/ZaString/Extensions/ZaInterpolatedStringHandler.cs b/src/ZaString/Extensions/ZaInterpolatedStringHandler.cs index 11530c4..2a023de 100644 --- a/src/ZaString/Extensions/ZaInterpolatedStringHandler.cs +++ b/src/ZaString/Extensions/ZaInterpolatedStringHandler.cs @@ -58,8 +58,171 @@ public void AppendFormatted(T value, string? format) where T : ISpanFormattab _builder.Append(value, format, _provider); } + public void AppendFormatted(T value, int alignment) where T : ISpanFormattable + { + if (alignment == 0) + { + _builder.Append(value, default, _provider); + return; + } + + var remaining = _builder.RemainingSpan; + if (remaining.Length < Math.Abs(alignment)) + { + throw new ArgumentOutOfRangeException("value", "The destination buffer is too small."); + } + + if (!value.TryFormat(remaining, out var charsWritten, default, _provider)) + { + throw new ArgumentOutOfRangeException("value", "The destination buffer is too small."); + } + + var padCount = alignment > 0 ? alignment - charsWritten : -alignment - charsWritten; + + if (padCount <= 0) + { + _builder.Advance(charsWritten); + return; + } + + if (alignment > 0) + { + if (remaining.Length < charsWritten + padCount) + { + throw new ArgumentOutOfRangeException("value", "The destination buffer is too small."); + } + + for (var i = charsWritten - 1; i >= 0; i--) + { + remaining[i + padCount] = remaining[i]; + } + + for (var i = 0; i < padCount; i++) + { + remaining[i] = ' '; + } + + _builder.Advance(charsWritten + padCount); + } + else + { + if (remaining.Length < charsWritten + padCount) + { + throw new ArgumentOutOfRangeException("value", "The destination buffer is too small."); + } + + remaining.Slice(charsWritten, padCount).Fill(' '); + _builder.Advance(charsWritten + padCount); + } + } + + public void AppendFormatted(T value, int alignment, string? format) where T : ISpanFormattable + { + if (alignment == 0) + { + _builder.Append(value, format, _provider); + return; + } + + var remaining = _builder.RemainingSpan; + if (remaining.Length < Math.Abs(alignment)) + { + throw new ArgumentOutOfRangeException("value", "The destination buffer is too small."); + } + + if (!value.TryFormat(remaining, out var charsWritten, format, _provider)) + { + throw new ArgumentOutOfRangeException("value", "The destination buffer is too small."); + } + + var padCount = alignment > 0 ? alignment - charsWritten : -alignment - charsWritten; + + if (padCount <= 0) + { + _builder.Advance(charsWritten); + return; + } + + if (alignment > 0) + { + if (remaining.Length < charsWritten + padCount) + { + throw new ArgumentOutOfRangeException("value", "The destination buffer is too small."); + } + + for (var i = charsWritten - 1; i >= 0; i--) + { + remaining[i + padCount] = remaining[i]; + } + + for (var i = 0; i < padCount; i++) + { + remaining[i] = ' '; + } + + _builder.Advance(charsWritten + padCount); + } + else + { + if (remaining.Length < charsWritten + padCount) + { + throw new ArgumentOutOfRangeException("value", "The destination buffer is too small."); + } + + remaining.Slice(charsWritten, padCount).Fill(' '); + _builder.Advance(charsWritten + padCount); + } + } + + + public void AppendFormatted(string? value, int alignment) + { + AppendAligned(value is null ? ReadOnlySpan.Empty : value.AsSpan(), alignment); + } + + public void AppendFormatted(string? value, int alignment, string? format) + { + AppendAligned(value is null ? ReadOnlySpan.Empty : value.AsSpan(), alignment); + } + + public void AppendFormatted(ReadOnlySpan value, int alignment) + { + AppendAligned(value, alignment); + } + + public void AppendFormatted(ReadOnlySpan value, int alignment, string? format) + { + AppendAligned(value, alignment); + } + + private void AppendAligned(ReadOnlySpan value, int alignment) + { + var width = Math.Abs(alignment); + var totalWidth = Math.Max(width, value.Length); + var remaining = _builder.RemainingSpan; + + if (remaining.Length < totalWidth) + { + throw new ArgumentOutOfRangeException("value", "The destination buffer is too small."); + } + + var padCount = totalWidth - value.Length; + if (alignment > 0) + { + remaining[..padCount].Fill(' '); + value.CopyTo(remaining[padCount..]); + } + else + { + value.CopyTo(remaining); + remaining.Slice(value.Length, padCount).Fill(' '); + } + + _builder.Advance(totalWidth); + } + public readonly 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 19cdf2b..3fc7495 100644 --- a/src/ZaString/Extensions/ZaSpanStringBuilderExtensions.cs +++ b/src/ZaString/Extensions/ZaSpanStringBuilderExtensions.cs @@ -1,3 +1,4 @@ +using System.Diagnostics.CodeAnalysis; using System.Globalization; using System.Runtime.CompilerServices; using ZaString.Core; @@ -116,7 +117,7 @@ public static ref ZaSpanStringBuilder RemoveLast(ref this ZaSpanStringBuilder bu ThrowOutOfRangeException(); } - builder.Advance(-count); + builder.SetLength(builder.Length - count); return ref builder; } @@ -234,6 +235,31 @@ public static ref ZaSpanStringBuilder AppendJoin(ref this ZaSpanStringBuilder return ref builder; } + + /// + /// Appends the elements of separated by . + /// Null elements are treated as empty strings. + /// + public static ref ZaSpanStringBuilder AppendJoin(ref this ZaSpanStringBuilder builder, ReadOnlySpan separator, IEnumerable values) + { + var isFirst = true; + foreach (var s in values) + { + if (!isFirst) + { + builder.Append(separator); + } + + isFirst = false; + if (s is not null) + { + builder.Append(s); + } + } + + return ref builder; + } + /// /// Attempts to append a read-only span of characters to the builder without throwing. /// @@ -329,9 +355,8 @@ public static bool TryAppendLine(ref this ZaSpanStringBuilder builder, string? v { var valueLength = value?.Length ?? 0; var newlineLength = Environment.NewLine.Length; - var required = valueLength + newlineLength; - if (required > builder.RemainingSpan.Length) + if (!HasCapacityForLine(valueLength, newlineLength, builder.RemainingSpan.Length)) { return false; } @@ -346,6 +371,12 @@ public static bool TryAppendLine(ref this ZaSpanStringBuilder builder, string? v builder.Advance(newlineLength); return true; } + + private static bool HasCapacityForLine(int valueLength, int newlineLength, int remainingLength) + { + return valueLength <= remainingLength && newlineLength <= remainingLength - valueLength; + } + /// /// Appends a read-only span of characters to the builder. /// @@ -605,6 +636,7 @@ public static ref ZaSpanStringBuilder AppendIf(ref this ZaSpanStringBuilder b /// /// Throws a standardized exception for out-of-range errors. /// + [DoesNotReturn] private static void ThrowOutOfRangeException() { throw new ArgumentOutOfRangeException("value", "The destination buffer is too small."); @@ -631,17 +663,28 @@ public static ref ZaSpanStringBuilder AppendJsonEscaped(ref this ZaSpanStringBui public static bool TryAppendJsonEscaped(ref this ZaSpanStringBuilder builder, ReadOnlySpan value) { - var required = GetJsonEscapedLength(value); - if (required > builder.RemainingSpan.Length) + var needsEscape = value.IndexOfAny("\"\\\b\f\n\r\t".AsSpan()) >= 0; + if (!needsEscape) { - return false; + for (int i = 0; i < value.Length; i++) + { + if (value[i] < ' ' || value[i] is '\u2028' or '\u2029') + { + needsEscape = true; + break; + } + } } - if (required == value.Length) + if (!needsEscape) { - value.CopyTo(builder.RemainingSpan); - builder.Advance(value.Length); - return true; + return builder.TryAppend(value); + } + + var required = GetJsonEscapedLength(value); + if (required > builder.RemainingSpan.Length) + { + return false; } var dest = builder.RemainingSpan; @@ -679,6 +722,22 @@ public static bool TryAppendJsonEscaped(ref this ZaSpanStringBuilder builder, Re dest[w++] = '\\'; dest[w++] = 't'; break; + case '\u2028': + dest[w++] = '\\'; + dest[w++] = 'u'; + dest[w++] = '2'; + dest[w++] = '0'; + dest[w++] = '2'; + dest[w++] = '8'; + break; + case '\u2029': + dest[w++] = '\\'; + dest[w++] = 'u'; + dest[w++] = '2'; + dest[w++] = '0'; + dest[w++] = '2'; + dest[w++] = '9'; + break; default: if (c < ' ') @@ -717,13 +776,17 @@ private static int GetJsonEscapedLength(ReadOnlySpan value) case '\n': case '\r': case '\t': - extra += 1; // becomes two chars instead of one + extra += 1; + break; + case '\u2028': + case '\u2029': + extra += 5; break; default: if (c < ' ') { - extra += 5; // \u00XX adds 5 extra over the original 1 + extra += 5; } break; @@ -758,17 +821,15 @@ public static ref ZaSpanStringBuilder AppendHtmlEscaped(ref this ZaSpanStringBui public static bool TryAppendHtmlEscaped(ref this ZaSpanStringBuilder builder, ReadOnlySpan value) { - var required = GetHtmlEscapedLength(value); - if (required > builder.RemainingSpan.Length) + if (value.IndexOfAny("&<>\"'".AsSpan()) < 0) { - return false; + return builder.TryAppend(value); } - if (required == value.Length) + var required = GetHtmlEscapedLength(value); + if (required > builder.RemainingSpan.Length) { - value.CopyTo(builder.RemainingSpan); - builder.Advance(value.Length); - return true; + return false; } var dest = builder.RemainingSpan; @@ -783,19 +844,19 @@ public static bool TryAppendHtmlEscaped(ref this ZaSpanStringBuilder builder, Re dest[w++] = 'm'; dest[w++] = 'p'; dest[w++] = ';'; - break; // & + break; case '<': dest[w++] = '&'; dest[w++] = 'l'; dest[w++] = 't'; dest[w++] = ';'; - break; // < + break; case '>': dest[w++] = '&'; dest[w++] = 'g'; dest[w++] = 't'; dest[w++] = ';'; - break; // > + break; case '"': dest[w++] = '&'; dest[w++] = 'q'; @@ -803,14 +864,14 @@ public static bool TryAppendHtmlEscaped(ref this ZaSpanStringBuilder builder, Re dest[w++] = 'o'; dest[w++] = 't'; dest[w++] = ';'; - break; // " + break; case '\'': dest[w++] = '&'; dest[w++] = '#'; dest[w++] = '3'; dest[w++] = '9'; dest[w++] = ';'; - break; // ' + break; default: dest[w++] = t; break; } } @@ -826,11 +887,11 @@ private static int GetHtmlEscapedLength(ReadOnlySpan value) { switch (t) { - case '&': extra += 4; break; // & (5) - 1 original = +4 + case '&': extra += 4; break; 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 += 3; break; + case '"': extra += 5; break; + case '\'': extra += 4; break; } } @@ -885,7 +946,7 @@ public static bool TryAppendCsvEscaped(ref this ZaSpanStringBuilder builder, Rea private static bool NeedsCsvQuoting(ReadOnlySpan value) { - if (value.Length == 0) return false; + if (value.Length == 0) return true; if (char.IsWhiteSpace(value[0]) || char.IsWhiteSpace(value[^1])) return true; foreach (var c in value) { @@ -921,6 +982,21 @@ public static ref ZaSpanStringBuilder AppendUrlEncoded(ref this ZaSpanStringBuil public static bool TryAppendUrlEncoded(ref this ZaSpanStringBuilder builder, ReadOnlySpan value) { + var needsEncoding = false; + for (int i = 0; i < value.Length; i++) + { + if (!IsUnreservedAscii(value[i])) + { + needsEncoding = true; + break; + } + } + + if (!needsEncoding) + { + return builder.TryAppend(value); + } + var required = GetUrlEncodedLength(value); if (required > builder.RemainingSpan.Length) { @@ -962,6 +1038,136 @@ public static bool TryAppendUrlEncoded(ref this ZaSpanStringBuilder builder, Rea return true; } + /// + /// Appends a form URL-encoded string (spaces encoded as '+' instead of '%20'). + /// + public static ref ZaSpanStringBuilder AppendFormUrlEncoded(ref this ZaSpanStringBuilder builder, ReadOnlySpan value) + { + if (!TryAppendFormUrlEncoded(ref builder, value)) + { + ThrowOutOfRangeException(); + } + + return ref builder; + } + + public static bool TryAppendFormUrlEncoded(ref this ZaSpanStringBuilder builder, ReadOnlySpan value) + { + var needsEncoding = false; + for (int i = 0; i < value.Length; i++) + { + if (value[i] == ' ' || !IsUnreservedAscii(value[i])) + { + needsEncoding = true; + break; + } + } + + if (!needsEncoding) + { + return builder.TryAppend(value); + } + + var required = GetFormUrlEncodedLengthReplacingInvalid(value); + if (required > builder.RemainingSpan.Length) + { + return false; + } + + var dest = builder.RemainingSpan; + var w = 0; + for (var i = 0; i < value.Length; i++) + { + var c = value[i]; + if (c == ' ') + { + dest[w++] = '+'; + } + else if (c <= 0x7F) + { + if (IsUnreservedAscii(c)) + { + dest[w++] = c; + } + else + { + dest[w++] = '%'; + WriteHexByte((byte)c, dest.Slice(w, 2)); + w += 2; + } + } + else if (char.IsHighSurrogate(c) && i + 1 < value.Length && char.IsLowSurrogate(value[i + 1])) + { + var low = value[++i]; + var codePoint = 0x10000 + (c - 0xD800 << 10 | low - 0xDC00); + w += PercentEncodeUtf8FromCodePoint(codePoint, dest[w..]); + } + else + { + w += WriteReplacementChar(dest[w..]); + } + } + + builder.Advance(required); + return true; + } + + private static int GetFormUrlEncodedLength(ReadOnlySpan value) + { + var length = 0; + for (var i = 0; i < value.Length; i++) + { + var c = value[i]; + if (c == ' ') + { + length += 1; + } + else if (c <= 0x7F) + { + length += IsUnreservedAscii(c) ? 1 : 3; + } + else if (char.IsHighSurrogate(c) && i + 1 < value.Length && char.IsLowSurrogate(value[i + 1])) + { + length += 4 * 3; + i++; + } + else + { + length += c <= 0x7FF ? 2 * 3 : 3 * 3; + } + } + + return length; + } + + private static int GetFormUrlEncodedLengthReplacingInvalid(ReadOnlySpan value) + { + var length = 0; + for (var i = 0; i < value.Length; i++) + { + var c = value[i]; + if (c == ' ') + { + length += 1; + } + else if (c <= 0x7F) + { + length += IsUnreservedAscii(c) ? 1 : 3; + } + else if (char.IsHighSurrogate(c) && i + 1 < value.Length && char.IsLowSurrogate(value[i + 1])) + { + length += 4 * 3; + i++; + } + else + { + length += 9; + } + } + + return length; + } + private static int GetUrlEncodedLength(ReadOnlySpan value) { var length = 0; @@ -974,12 +1180,11 @@ private static int GetUrlEncodedLength(ReadOnlySpan value) } 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 + length += 4 * 3; + i++; } else { - // Non-surrogate BMP char: 0x80..0x7FF => 2 bytes; 0x800..0xFFFF => 3 bytes length += c <= 0x7FF ? 2 * 3 : 3 * 3; } } @@ -987,11 +1192,34 @@ private static int GetUrlEncodedLength(ReadOnlySpan value) return length; } + private static int GetUrlEncodedLengthReplacingInvalid(ReadOnlySpan value) + { + var length = 0; + for (var i = 0; i < value.Length; i++) + { + var c = value[i]; + if (c <= 0x7F) + { + length += IsUnreservedAscii(c) ? 1 : 3; + } + else if (char.IsHighSurrogate(c) && i + 1 < value.Length && char.IsLowSurrogate(value[i + 1])) + { + length += 4 * 3; + i++; + } + else + { + length += 9; + } + } + + 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)); @@ -1041,6 +1269,115 @@ private static int PercentEncodeUtf8FromCodePoint(int codePoint, Span dest } } + private static int WriteReplacementChar(Span dest) + { + dest[0] = '%'; + dest[1] = 'E'; + dest[2] = 'F'; + dest[3] = '%'; + dest[4] = 'B'; + dest[5] = 'F'; + dest[6] = '%'; + dest[7] = 'B'; + dest[8] = 'D'; + return 9; + } + + private static int WriteUrlEncoded(ReadOnlySpan value, Span dest) + { + 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..]); + } + } + + return w; + } + + private static int WriteUrlEncodedReplacingInvalid(ReadOnlySpan value, Span dest) + { + 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 + { + w += WriteReplacementChar(dest[w..]); + } + } + + return w; + } + + private static int GetQueryParamLength(ReadOnlySpan key, ReadOnlySpan value, bool urlEncode) + { + var keyLength = urlEncode ? GetUrlEncodedLengthReplacingInvalid(key) : key.Length; + var valueLength = urlEncode ? GetUrlEncodedLengthReplacingInvalid(value) : value.Length; + return 1 + keyLength + 1 + valueLength; + } + + private static void WriteQueryParam(Span destination, char prefix, ReadOnlySpan key, ReadOnlySpan value, bool urlEncode) + { + destination[0] = prefix; + var written = 1; + + if (urlEncode) + { + written += WriteUrlEncodedReplacingInvalid(key, destination[written..]); + destination[written++] = '='; + written += WriteUrlEncodedReplacingInvalid(value, destination[written..]); + return; + } + + key.CopyTo(destination[written..]); + written += key.Length; + destination[written++] = '='; + value.CopyTo(destination[written..]); + } + public static ref ZaSpanStringBuilder AppendPathSegment(ref this ZaSpanStringBuilder builder, ReadOnlySpan segment, char separator = '/') { if (builder.Length > 0 && builder[^1] != separator) @@ -1048,7 +1385,6 @@ public static ref ZaSpanStringBuilder AppendPathSegment(ref this ZaSpanStringBui builder.Append(separator); } - // Trim leading separators in segment var start = 0; while (start < segment.Length && segment[start] == separator) start++; if (start < segment.Length) @@ -1061,23 +1397,65 @@ public static ref ZaSpanStringBuilder AppendPathSegment(ref this ZaSpanStringBui public static ref ZaSpanStringBuilder AppendQueryParam(ref this ZaSpanStringBuilder builder, ReadOnlySpan key, ReadOnlySpan value, bool urlEncode = true, bool isFirst = false) { - builder.Append(isFirst ? '?' : '&'); - if (urlEncode) + var required = GetQueryParamLength(key, value, urlEncode); + if (required > builder.RemainingSpan.Length) { - builder.AppendUrlEncoded(key); - builder.Append('='); - builder.AppendUrlEncoded(value); + ThrowOutOfRangeException(); } - else + + WriteQueryParam(builder.RemainingSpan, isFirst ? '?' : '&', key, value, urlEncode); + builder.Advance(required); + return ref builder; + } + + public static ref ZaSpanStringBuilder AppendQueryParam(ref this ZaSpanStringBuilder builder, ReadOnlySpan key, ReadOnlySpan value, ref bool isFirst, bool urlEncode = true) + { + var required = GetQueryParamLength(key, value, urlEncode); + if (required > builder.RemainingSpan.Length) { - builder.Append(key); - builder.Append('='); - builder.Append(value); + ThrowOutOfRangeException(); } + WriteQueryParam(builder.RemainingSpan, isFirst ? '?' : '&', key, value, urlEncode); + builder.Advance(required); + isFirst = false; return ref builder; } + /// + /// Appends a formatted string using the specified format and arguments. + /// + /// Note: This overload currently delegates to + /// and allocates a string due to string.Format requiring a string format parameter. + /// A future optimization may implement a zero-allocation format parser for simple positional placeholders. + /// + /// + /// The builder instance. + /// A composite format string. + /// An object array that contains zero or more objects to format. + public static ref ZaSpanStringBuilder AppendFormat(ref this ZaSpanStringBuilder builder, ReadOnlySpan format, params object?[] args) + { + return ref builder.AppendFormat(CultureInfo.InvariantCulture, format, args); + } + + /// + /// Appends a formatted string using the specified format provider, format, and arguments. + /// + /// Note: This overload currently delegates to + /// and allocates a string due to string.Format requiring a string format parameter. + /// A future optimization may implement a zero-allocation format parser for simple positional placeholders. + /// + /// + /// The builder instance. + /// An object that supplies culture-specific formatting information. + /// A composite format string. + /// An object array that contains zero or more objects to format. + public static ref ZaSpanStringBuilder AppendFormat(ref this ZaSpanStringBuilder builder, IFormatProvider? formatProvider, ReadOnlySpan format, params object?[] args) + { + var formatted = string.Format(formatProvider, format.ToString(), args); + return ref builder.Append(formatted.AsSpan()); + } + public static ref ZaSpanStringBuilder AppendFormat(ref this ZaSpanStringBuilder builder, string format, params object?[] args) { return ref builder.AppendFormat(CultureInfo.InvariantCulture, format, args); @@ -1088,4 +1466,4 @@ public static ref ZaSpanStringBuilder AppendFormat(ref this ZaSpanStringBuilder 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 index fa12a9d..37957a0 100644 --- a/src/ZaString/Extensions/ZaUtf8SpanWriterExtensions.cs +++ b/src/ZaString/Extensions/ZaUtf8SpanWriterExtensions.cs @@ -29,13 +29,14 @@ public static ref ZaUtf8SpanWriter Append(ref this ZaUtf8SpanWriter writer, stri { if (value is not null) { - var bytes = Encoding.UTF8.GetByteCount(value); + var span = value.AsSpan(); + var bytes = Encoding.UTF8.GetByteCount(span); if (bytes > writer.RemainingSpan.Length) { ThrowOutOfRangeException(); } - var written = Encoding.UTF8.GetBytes(value, writer.RemainingSpan); + var written = Encoding.UTF8.GetBytes(span, writer.RemainingSpan); writer.Advance(written); } @@ -44,19 +45,15 @@ 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 - }); + Span chars = stackalloc char[1]; + chars[0] = value; + var bytes = Encoding.UTF8.GetByteCount(chars); if (bytes > writer.RemainingSpan.Length) { ThrowOutOfRangeException(); } - var written = Encoding.UTF8.GetBytes(stackalloc char[1] - { - value - }, writer.RemainingSpan); + var written = Encoding.UTF8.GetBytes(chars, writer.RemainingSpan); writer.Advance(written); return ref writer; } diff --git a/src/ZaString/ZaString.csproj b/src/ZaString/ZaString.csproj index 7de1358..295777c 100644 --- a/src/ZaString/ZaString.csproj +++ b/src/ZaString/ZaString.csproj @@ -1,6 +1,11 @@  net8.0;net9.0;net10.0 + enable enable ZaString @@ -19,11 +24,9 @@ true true true - true + true true - 0.2.6 - 0.2.6 - true + v diff --git a/tests/ZaString.Benchmarks/StringBuildingBenchmarks.cs b/tests/ZaString.Benchmarks/StringBuildingBenchmarks.cs index d615aba..407340d 100644 --- a/tests/ZaString.Benchmarks/StringBuildingBenchmarks.cs +++ b/tests/ZaString.Benchmarks/StringBuildingBenchmarks.cs @@ -45,7 +45,7 @@ public string StringInterpolation_BasicAppends() } [Benchmark] - public int ZaSpanStringBuilder_BasicAppends() + public string ZaSpanStringBuilder_BasicAppends() { Span buffer = stackalloc char[200]; var builder = ZaSpanStringBuilder.Create(buffer); @@ -59,7 +59,7 @@ public int ZaSpanStringBuilder_BasicAppends() .Append(", Active: ") .Append(TestBool); - return builder.Length; + return builder.ToString(); } [Benchmark] @@ -100,7 +100,7 @@ public string StringBuilder_ManyAppends() } [Benchmark] - public int ZaSpanStringBuilder_ManyAppends() + public string ZaSpanStringBuilder_ManyAppends() { Span buffer = stackalloc char[500]; var builder = ZaSpanStringBuilder.Create(buffer); @@ -115,9 +115,7 @@ public int ZaSpanStringBuilder_ManyAppends() .Append(" - "); } - builder.AsSpan(); - - return builder.Length; + return builder.ToString(); } [Benchmark] @@ -134,7 +132,7 @@ public string StringBuilder_NumberFormatting() } [Benchmark] - public int ZaSpanStringBuilder_NumberFormatting() + public string ZaSpanStringBuilder_NumberFormatting() { Span buffer = stackalloc char[200]; var builder = ZaSpanStringBuilder.Create(buffer); @@ -146,7 +144,7 @@ public int ZaSpanStringBuilder_NumberFormatting() .Append(", Percentage: ") .Append(0.85, "P2"); - return builder.Length; + return builder.ToString(); } [Benchmark] @@ -164,7 +162,7 @@ public string StringBuilder_LargeString() } [Benchmark] - public int ZaSpanStringBuilder_LargeString() + public string ZaSpanStringBuilder_LargeString() { Span buffer = stackalloc char[8000]; var builder = ZaSpanStringBuilder.Create(buffer); @@ -176,8 +174,7 @@ public int ZaSpanStringBuilder_LargeString() .Append(" of the benchmark test. "); } - builder.AsSpan(); - return builder.Length; + return builder.ToString(); } [Benchmark] @@ -193,7 +190,7 @@ public string StringBuilder_DateTimeFormatting() } [Benchmark] - public int ZaSpanStringBuilder_DateTimeFormatting() + public string ZaSpanStringBuilder_DateTimeFormatting() { Span buffer = stackalloc char[200]; var builder = ZaSpanStringBuilder.Create(buffer); @@ -204,8 +201,7 @@ public int ZaSpanStringBuilder_DateTimeFormatting() .Append(" at ") .Append(now, "HH:mm:ss"); - builder.AsSpan(); - return builder.Length; + return builder.ToString(); } } @@ -227,13 +223,12 @@ public string ToString_Integer() } [Benchmark] - public int ZaSpanStringBuilder_Integer() + public string ZaSpanStringBuilder_Integer() { Span buffer = stackalloc char[20]; var builder = ZaSpanStringBuilder.Create(buffer); builder.Append(TestInt); - builder.AsSpan(); - return builder.Length; + return builder.ToString(); } [Benchmark] @@ -245,13 +240,12 @@ public string ToString_Double() } [Benchmark] - public int ZaSpanStringBuilder_Double() + public string ZaSpanStringBuilder_Double() { Span buffer = stackalloc char[30]; var builder = ZaSpanStringBuilder.Create(buffer); builder.Append(TestDouble); - builder.AsSpan(); - return builder.Length; + return builder.ToString(); } [Benchmark] @@ -263,13 +257,12 @@ public string ToString_Float() } [Benchmark] - public int ZaSpanStringBuilder_Float() + public string ZaSpanStringBuilder_Float() { Span buffer = stackalloc char[20]; var builder = ZaSpanStringBuilder.Create(buffer); builder.Append(TestFloat); - builder.AsSpan(); - return builder.Length; + return builder.ToString(); } [Benchmark] @@ -281,13 +274,12 @@ public string ToString_Long() } [Benchmark] - public int ZaSpanStringBuilder_Long() + public string ZaSpanStringBuilder_Long() { Span buffer = stackalloc char[25]; var builder = ZaSpanStringBuilder.Create(buffer); builder.Append(TestLong); - builder.AsSpan(); - return builder.Length; + return builder.ToString(); } [Benchmark] @@ -299,12 +291,12 @@ public string ToString_IntegerFormatted() } [Benchmark] - public int ZaSpanStringBuilder_IntegerFormatted() + public string ZaSpanStringBuilder_IntegerFormatted() { Span buffer = stackalloc char[30]; var builder = ZaSpanStringBuilder.Create(buffer); builder.Append(TestInt, "N0"); - return builder.Length; + return builder.ToString(); } [Benchmark] @@ -316,13 +308,12 @@ public string ToString_DoubleFormatted() } [Benchmark] - public int ZaSpanStringBuilder_DoubleFormatted() + public string ZaSpanStringBuilder_DoubleFormatted() { Span buffer = stackalloc char[20]; var builder = ZaSpanStringBuilder.Create(buffer); builder.Append(TestDouble, "F2"); - builder.AsSpan(); - return builder.Length; + return builder.ToString(); } } diff --git a/tests/ZaString.Tests/ZaPooledStringBuilderTests.cs b/tests/ZaString.Tests/ZaPooledStringBuilderTests.cs index c4eaa22..bab21e1 100644 --- a/tests/ZaString.Tests/ZaPooledStringBuilderTests.cs +++ b/tests/ZaString.Tests/ZaPooledStringBuilderTests.cs @@ -1,8 +1,93 @@ +using System.Buffers; using System.Globalization; using ZaString.Core; namespace ZaString.Tests; +/// +/// A custom ISpanFormattable that always fails to format, used to test safety limits. +/// +public readonly struct FailingFormattable : ISpanFormattable +{ + public bool TryFormat(Span destination, out int charsWritten, ReadOnlySpan format, IFormatProvider? provider) + { + charsWritten = 0; + return false; + } + + public string ToString(string? format, IFormatProvider? formatProvider) + { + return string.Empty; + } + + public override string ToString() + { + return string.Empty; + } +} + +file sealed class LimitedArrayPool : ArrayPool +{ + private readonly int _maxCapacity; + + public LimitedArrayPool(int maxCapacity) + { + _maxCapacity = maxCapacity; + } + + public override char[] Rent(int minimumLength) + { + if (minimumLength > _maxCapacity) + { + throw new InvalidOperationException("Pool capacity exceeded"); + } + + return new char[minimumLength]; + } + + public override void Return(char[] array, bool clearArray = false) + { + } +} + +file sealed class ThrowingArrayPool : ArrayPool +{ + private int _rentCount; + + public override char[] Rent(int minimumLength) + { + if (_rentCount++ == 0) + { + return new char[Math.Max(1, minimumLength)]; + } + + throw new InvalidOperationException("Pool rent failed"); + } + + public override void Return(char[] array, bool clearArray = false) + { + } +} + + +public readonly struct LargeFormattable : ISpanFormattable +{ + public bool TryFormat(Span destination, out int charsWritten, ReadOnlySpan format, IFormatProvider? provider) + { + if (destination.Length < 2_000_000) + { + charsWritten = 0; + return false; + } + + "big".AsSpan().CopyTo(destination); + charsWritten = 3; + return true; + } + + public string ToString(string? format, IFormatProvider? formatProvider) => "big"; +} + public class ZaPooledStringBuilderTests { [Fact] @@ -205,7 +290,90 @@ public void UsingStatement_DisposesCorrectly() } // Builder should be disposed after using block - Assert.Throws(() => builder.Append("Test")); + Assert.Throws(() => builder.Append("Test")); + } + + [Fact] + public void Append_AfterDispose_ThrowsObjectDisposedException() + { + var builder = ZaPooledStringBuilder.Rent(128); + builder.Dispose(); + + Assert.Throws(() => builder.Append("test")); + Assert.Throws(() => builder.Append('x')); + Assert.Throws(() => builder.Append("test".AsSpan())); + Assert.Throws(() => builder.Append(true)); + Assert.Throws(() => builder.Append(42)); + } + + [Fact] + public void ToString_AfterDispose_ThrowsObjectDisposedException() + { + var builder = ZaPooledStringBuilder.Rent(128); + builder.Append("Test"); + builder.Dispose(); + + Assert.Throws(() => builder.ToString()); + } + + [Fact] + public void AsSpan_AfterDispose_ThrowsObjectDisposedException() + { + var builder = ZaPooledStringBuilder.Rent(128); + builder.Append("Test"); + builder.Dispose(); + + Assert.Throws(() => builder.AsSpan()); + } + + [Fact] + public void AppendLine_AfterDispose_ThrowsObjectDisposedException() + { + var builder = ZaPooledStringBuilder.Rent(128); + builder.Dispose(); + + Assert.Throws(() => builder.AppendLine()); + Assert.Throws(() => builder.AppendLine("test")); + } + + [Fact] + public void ToUtf8NullTerminated_AfterDispose_ThrowsObjectDisposedException() + { + var builder = ZaPooledStringBuilder.Rent(128); + builder.Append("Test"); + builder.Dispose(); + + Assert.Throws(() => builder.ToUtf8NullTerminated()); + } + + [Fact] + public void TryToUtf8NullTerminated_AfterDispose_ThrowsObjectDisposedException() + { + var builder = ZaPooledStringBuilder.Rent(128); + builder.Append("Test"); + builder.Dispose(); + + Assert.Throws(() => builder.TryToUtf8NullTerminated(Span.Empty, out _)); + } + + [Fact] + public void Clear_AfterDispose_ThrowsObjectDisposedException() + { + var builder = ZaPooledStringBuilder.Rent(128); + builder.Append("Test"); + builder.Dispose(); + + Assert.Throws(() => builder.Clear()); + } + + [Fact] + public void Capacity_AfterDispose_ThrowsObjectDisposedException() + { + var builder = ZaPooledStringBuilder.Rent(128); + builder.Append("Test"); + builder.Dispose(); + + Assert.Throws(() => _ = builder.Capacity); } [Theory] @@ -240,4 +408,297 @@ public void Append_WithCulture_WorksCorrectly() var expected = 1234.56.ToString("C", culture); Assert.Equal(expected, builder.ToString()); } -} \ No newline at end of file + + [Fact] + public void Append_FailingISpanFormattable_ThrowsWhenPoolExhausted() + { + var pool = new LimitedArrayPool(1024); + using var builder = ZaPooledStringBuilder.Rent(4, pool); + var failingValue = new FailingFormattable(); + + Assert.Throws(() => builder.Append(failingValue)); + } + + [Fact] + public void EnsureCapacity_Overflow_ThrowsArgumentOutOfRangeException() + { + using var builder = ZaPooledStringBuilder.Rent(4); + builder.Append(new string('x', 100)); + + var ensureCapacity = typeof(ZaPooledStringBuilder).GetMethod("EnsureCapacity", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); + Assert.NotNull(ensureCapacity); + + var ex = Assert.Throws(() => ensureCapacity.Invoke(builder, new object[] { int.MaxValue })); + Assert.IsType(ex.InnerException); + } + + [Fact] + public void SetLength_TruncatesCorrectly() + { + using var builder = ZaPooledStringBuilder.Rent(4); + builder.Append("Hello World"); + Assert.Equal(11, builder.Length); + + builder.SetLength(5); + Assert.Equal(5, builder.Length); + Assert.Equal("Hello", builder.ToString()); + } + + [Fact] + public void SetLength_Zero_ClearsContent() + { + using var builder = ZaPooledStringBuilder.Rent(4); + builder.Append("Test"); + Assert.Equal(4, builder.Length); + + builder.SetLength(0); + Assert.Equal(0, builder.Length); + Assert.Equal("", builder.ToString()); + } + + [Fact] + public void SetLength_ExceedsLength_ThrowsArgumentOutOfRangeException() + { + using var builder = ZaPooledStringBuilder.Rent(4); + builder.Append("Hi"); + + Assert.Throws(() => builder.SetLength(5)); + } + + [Fact] + public void SetLength_Negative_ThrowsArgumentOutOfRangeException() + { + using var builder = ZaPooledStringBuilder.Rent(4); + builder.Append("Hi"); + + Assert.Throws(() => builder.SetLength(-1)); + } + + [Fact] + public void RemoveLast_RemovesCorrectCount() + { + using var builder = ZaPooledStringBuilder.Rent(4); + builder.Append("Hello World"); + Assert.Equal(11, builder.Length); + + builder.RemoveLast(6); + Assert.Equal(5, builder.Length); + Assert.Equal("Hello", builder.ToString()); + } + + [Fact] + public void RemoveLast_AllCharacters_Clears() + { + using var builder = ZaPooledStringBuilder.Rent(4); + builder.Append("Test"); + + builder.RemoveLast(4); + Assert.Equal(0, builder.Length); + Assert.Equal("", builder.ToString()); + } + + [Fact] + public void RemoveLast_ExceedsLength_ThrowsArgumentOutOfRangeException() + { + using var builder = ZaPooledStringBuilder.Rent(4); + builder.Append("Hi"); + + Assert.Throws(() => builder.RemoveLast(5)); + } + + [Fact] + public void RemoveLast_Negative_ThrowsArgumentOutOfRangeException() + { + using var builder = ZaPooledStringBuilder.Rent(4); + builder.Append("Hi"); + + Assert.Throws(() => builder.RemoveLast(-1)); + } + + [Fact] + public void Indexer_Get_ReturnsCorrectCharacter() + { + using var builder = ZaPooledStringBuilder.Rent(4); + builder.Append("Hello"); + + Assert.Equal('H', builder[0]); + Assert.Equal('e', builder[1]); + Assert.Equal('o', builder[4]); + } + + [Fact] + public void Indexer_Get_OutOfRange_ThrowsArgumentOutOfRangeException() + { + using var builder = ZaPooledStringBuilder.Rent(4); + builder.Append("Hi"); + + Assert.Throws(() => builder[2]); + } + + [Fact] + public void Indexer_Set_ModifiesCharacter() + { + using var builder = ZaPooledStringBuilder.Rent(4); + builder.Append("Hello"); + + builder[0] = 'J'; + builder[4] = 'y'; + + Assert.Equal("Jelly", builder.ToString()); + } + + [Fact] + public void Indexer_Set_OutOfRange_ThrowsArgumentOutOfRangeException() + { + using var builder = ZaPooledStringBuilder.Rent(4); + builder.Append("Hi"); + + Assert.Throws(() => builder[2] = 'x'); + } + + [Fact] + public void TryAppend_ReadOnlySpan_Succeeds() + { + using var builder = ZaPooledStringBuilder.Rent(4); + var ok = builder.TryAppend("Hello".AsSpan()); + + Assert.True(ok); + Assert.Equal("Hello", builder.ToString()); + } + + [Fact] + public void TryAppend_String_Succeeds() + { + using var builder = ZaPooledStringBuilder.Rent(4); + var ok = builder.TryAppend("Hello"); + + Assert.True(ok); + Assert.Equal("Hello", builder.ToString()); + } + + [Fact] + public void TryAppend_String_Null_ReturnsTrue_NoChange() + { + using var builder = ZaPooledStringBuilder.Rent(4); + var ok = builder.TryAppend(null); + + Assert.True(ok); + Assert.Equal("", builder.ToString()); + Assert.Equal(0, builder.Length); + } + + [Fact] + public void TryAppend_Char_Succeeds() + { + using var builder = ZaPooledStringBuilder.Rent(4); + var ok = builder.TryAppend('A'); + + Assert.True(ok); + Assert.Equal("A", builder.ToString()); + Assert.Equal(1, builder.Length); + } + + [Fact] + public void TryAppend_UnexpectedPoolFailure_PropagatesException() + { + var pool = new ThrowingArrayPool(); + + using var builder = ZaPooledStringBuilder.Rent(1, pool); + builder.Append('A'); + + Assert.Throws(() => builder.TryAppend("BC".AsSpan())); + Assert.Throws(() => builder.TryAppend('B')); + } + + [Fact] + public void SetLength_OnEmptyBuilder_Works() + { + using var builder = ZaPooledStringBuilder.Rent(4); + builder.SetLength(0); + Assert.Equal(0, builder.Length); + Assert.Equal("", builder.ToString()); + } + + [Fact] + public void RemoveLast_OnEmptyBuilder_ThrowsArgumentOutOfRangeException() + { + using var builder = ZaPooledStringBuilder.Rent(4); + Assert.Throws(() => builder.RemoveLast(1)); + } + + [Fact] + public void SetLength_AfterDispose_ThrowsObjectDisposedException() + { + var builder = ZaPooledStringBuilder.Rent(128); + builder.Append("Test"); + builder.Dispose(); + + Assert.Throws(() => builder.SetLength(0)); + } + + [Fact] + public void RemoveLast_AfterDispose_ThrowsObjectDisposedException() + { + var builder = ZaPooledStringBuilder.Rent(128); + builder.Append("Test"); + builder.Dispose(); + + Assert.Throws(() => builder.RemoveLast(1)); + } + + [Fact] + public void Indexer_Get_AfterDispose_ThrowsObjectDisposedException() + { + var builder = ZaPooledStringBuilder.Rent(128); + builder.Append("Test"); + builder.Dispose(); + + Assert.Throws(() => _ = builder[0]); + } + + [Fact] + public void Indexer_Set_AfterDispose_ThrowsObjectDisposedException() + { + var builder = ZaPooledStringBuilder.Rent(128); + builder.Append("Test"); + builder.Dispose(); + + Assert.Throws(() => builder[0] = 'X'); + } + + [Fact] + public void TryAppend_AfterDispose_ThrowsObjectDisposedException() + { + var builder = ZaPooledStringBuilder.Rent(128); + builder.Append("Test"); + builder.Dispose(); + + Assert.Throws(() => builder.TryAppend("x")); + Assert.Throws(() => builder.TryAppend('x')); + Assert.Throws(() => builder.TryAppend("x".AsSpan())); + } + + [Fact] + public void Append_LargeISpanFormattable_GrowsUntilItSucceeds() + { + using var builder = ZaPooledStringBuilder.Rent(4); + + builder.Append(new LargeFormattable()); + + Assert.Equal("big", builder.ToString()); + } + + [Theory] + [InlineData(200, 300, 300)] + [InlineData(200, 100, 300)] + [InlineData(0, 1, 256)] + public void ComputeExpandedCapacity_ClampsAndGrowsSafely(int currentCapacity, int required, int expected) + { + var computeExpandedCapacity = typeof(ZaPooledStringBuilder) + .GetMethod("ComputeExpandedCapacity", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static); + + Assert.NotNull(computeExpandedCapacity); + var actual = (int)computeExpandedCapacity.Invoke(null, new object[] { currentCapacity, required })!; + Assert.Equal(expected, actual); + } +} diff --git a/tests/ZaString.Tests/ZaPooledStringBuilderUtf8Tests.cs b/tests/ZaString.Tests/ZaPooledStringBuilderUtf8Tests.cs index 820618a..ac6939b 100644 --- a/tests/ZaString.Tests/ZaPooledStringBuilderUtf8Tests.cs +++ b/tests/ZaString.Tests/ZaPooledStringBuilderUtf8Tests.cs @@ -123,4 +123,38 @@ public unsafe void ZaUtf8Handle_Pointer_ReturnsValidPointer() Assert.Equal((byte)'t', *(ptr + 3)); Assert.Equal(0, *(ptr + 4)); } + + [Fact] + public unsafe void TryToUtf8NullTerminated_NegativeLength_ReturnsFalse() + { + using var builder = ZaPooledStringBuilder.Rent(); + builder.Append("Test"); + + var success = builder.TryToUtf8NullTerminated(null, -1, out var bytesWritten); + + Assert.False(success); + Assert.Equal(0, bytesWritten); + } + + [Fact] + public void ZaUtf8Handle_IsRefStruct_PreventsHeapAllocation() + { + Assert.True(typeof(ZaUtf8Handle).IsByRefLike); + } + + [Fact] + public unsafe void TryToUtf8NullTerminated_NegativeLengthWithBuffer_ReturnsFalse() + { + using var builder = ZaPooledStringBuilder.Rent(); + builder.Append("Test"); + + Span buffer = stackalloc byte[10]; + fixed (byte* ptr = buffer) + { + var success = builder.TryToUtf8NullTerminated(ptr, -1, out var bytesWritten); + + Assert.False(success); + Assert.Equal(0, bytesWritten); + } + } } diff --git a/tests/ZaString.Tests/ZaSpanStringBuilderAppendHelpersTests.cs b/tests/ZaString.Tests/ZaSpanStringBuilderAppendHelpersTests.cs index 01a3cbf..74e6e0f 100644 --- a/tests/ZaString.Tests/ZaSpanStringBuilderAppendHelpersTests.cs +++ b/tests/ZaString.Tests/ZaSpanStringBuilderAppendHelpersTests.cs @@ -82,4 +82,40 @@ public void AppendJoin_ISpanFormattable_Works_WithProvider() Assert.Equal("1,5; 2,5", builder.AsSpan()); } + + [Fact] + public void AppendJoin_IEnumerableString_Works() + { + Span buffer = stackalloc char[32]; + var builder = ZaSpanStringBuilder.Create(buffer); + + var values = new List { "a", null, "c" }; + builder.AppendJoin(", ".AsSpan(), values); + + Assert.Equal("a, , c", builder.AsSpan()); + } + + [Fact] + public void AppendJoin_IEnumerableString_EmptyList() + { + Span buffer = stackalloc char[32]; + var builder = ZaSpanStringBuilder.Create(buffer); + + var values = new List(); + builder.AppendJoin(", ".AsSpan(), values); + + Assert.Equal("", builder.AsSpan()); + } + + [Fact] + public void AppendJoin_IEnumerableString_SingleItem() + { + Span buffer = stackalloc char[32]; + var builder = ZaSpanStringBuilder.Create(buffer); + + var values = new List { "only" }; + builder.AppendJoin(", ".AsSpan(), values); + + Assert.Equal("only", builder.AsSpan()); + } } \ No newline at end of file diff --git a/tests/ZaString.Tests/ZaSpanStringBuilderBasicTests.cs b/tests/ZaString.Tests/ZaSpanStringBuilderBasicTests.cs index 25bd8ed..f198d39 100644 --- a/tests/ZaString.Tests/ZaSpanStringBuilderBasicTests.cs +++ b/tests/ZaString.Tests/ZaSpanStringBuilderBasicTests.cs @@ -173,6 +173,45 @@ public void Advance_UpdatesLengthCorrectly() Assert.Equal("Test", builder.AsSpan()); } + [Fact] + public void Advance_NegativeCount_ThrowsArgumentOutOfRangeException() + { + Span buffer = stackalloc char[100]; + var builder = ZaSpanStringBuilder.Create(buffer); + + var threw = false; + try + { + builder.Advance(-1); + } + catch (ArgumentOutOfRangeException) + { + threw = true; + } + + Assert.True(threw); + } + + [Fact] + public void Advance_ExceedsCapacity_ThrowsArgumentOutOfRangeException() + { + Span buffer = stackalloc char[10]; + var builder = ZaSpanStringBuilder.Create(buffer); + builder.Append("Hello"); + + var threw = false; + try + { + builder.Advance(10); + } + catch (ArgumentOutOfRangeException) + { + threw = true; + } + + Assert.True(threw); + } + [Fact] public void Append_ExactBufferSize_Works() { @@ -284,7 +323,7 @@ public void Indexer_ModifyMultipleCharacters_WorksCorrectly() } [Fact] - public void Indexer_NegativeIndex_ThrowsIndexOutOfRangeException() + public void Indexer_NegativeIndex_ThrowsArgumentOutOfRangeException() { Span buffer = stackalloc char[100]; var builder = ZaSpanStringBuilder.Create(buffer); @@ -297,7 +336,7 @@ public void Indexer_NegativeIndex_ThrowsIndexOutOfRangeException() { var _ = builder[-1]; } - catch (IndexOutOfRangeException) + catch (ArgumentOutOfRangeException) { threwForRead = true; } @@ -306,7 +345,7 @@ public void Indexer_NegativeIndex_ThrowsIndexOutOfRangeException() { builder[-1] = 'X'; } - catch (IndexOutOfRangeException) + catch (ArgumentOutOfRangeException) { threwForWrite = true; } @@ -316,7 +355,7 @@ public void Indexer_NegativeIndex_ThrowsIndexOutOfRangeException() } [Fact] - public void Indexer_IndexEqualToLength_ThrowsIndexOutOfRangeException() + public void Indexer_IndexEqualToLength_ThrowsArgumentOutOfRangeException() { Span buffer = stackalloc char[100]; var builder = ZaSpanStringBuilder.Create(buffer); @@ -329,7 +368,7 @@ public void Indexer_IndexEqualToLength_ThrowsIndexOutOfRangeException() { var _ = builder[4]; } - catch (IndexOutOfRangeException) + catch (ArgumentOutOfRangeException) { threwForRead = true; } @@ -338,7 +377,7 @@ public void Indexer_IndexEqualToLength_ThrowsIndexOutOfRangeException() { builder[4] = 'X'; } - catch (IndexOutOfRangeException) + catch (ArgumentOutOfRangeException) { threwForWrite = true; } @@ -348,7 +387,7 @@ public void Indexer_IndexEqualToLength_ThrowsIndexOutOfRangeException() } [Fact] - public void Indexer_IndexGreaterThanLength_ThrowsIndexOutOfRangeException() + public void Indexer_IndexGreaterThanLength_ThrowsArgumentOutOfRangeException() { Span buffer = stackalloc char[100]; var builder = ZaSpanStringBuilder.Create(buffer); @@ -361,7 +400,7 @@ public void Indexer_IndexGreaterThanLength_ThrowsIndexOutOfRangeException() { var _ = builder[10]; } - catch (IndexOutOfRangeException) + catch (ArgumentOutOfRangeException) { threwForRead = true; } @@ -370,7 +409,7 @@ public void Indexer_IndexGreaterThanLength_ThrowsIndexOutOfRangeException() { builder[10] = 'X'; } - catch (IndexOutOfRangeException) + catch (ArgumentOutOfRangeException) { threwForWrite = true; } @@ -380,7 +419,7 @@ public void Indexer_IndexGreaterThanLength_ThrowsIndexOutOfRangeException() } [Fact] - public void Indexer_EmptyBuilder_ThrowsIndexOutOfRangeException() + public void Indexer_EmptyBuilder_ThrowsArgumentOutOfRangeException() { Span buffer = stackalloc char[100]; var builder = ZaSpanStringBuilder.Create(buffer); @@ -392,7 +431,7 @@ public void Indexer_EmptyBuilder_ThrowsIndexOutOfRangeException() { var _ = builder[0]; } - catch (IndexOutOfRangeException) + catch (ArgumentOutOfRangeException) { threwForRead = true; } @@ -401,7 +440,7 @@ public void Indexer_EmptyBuilder_ThrowsIndexOutOfRangeException() { builder[0] = 'X'; } - catch (IndexOutOfRangeException) + catch (ArgumentOutOfRangeException) { threwForWrite = true; } diff --git a/tests/ZaString.Tests/ZaSpanStringBuilderEscapingTests.cs b/tests/ZaString.Tests/ZaSpanStringBuilderEscapingTests.cs index 7843240..6a678e2 100644 --- a/tests/ZaString.Tests/ZaSpanStringBuilderEscapingTests.cs +++ b/tests/ZaString.Tests/ZaSpanStringBuilderEscapingTests.cs @@ -27,6 +27,18 @@ public void TryAppendJsonEscaped_ControlChars_Unicode() Assert.Equal("A\\u0001B", builder.AsSpan()); } + [Fact] + public void TryAppendJsonEscaped_LineAndParagraphSeparators_Unicode() + { + Span buffer = stackalloc char[64]; + var builder = ZaSpanStringBuilder.Create(buffer); + + var ok = builder.TryAppendJsonEscaped("A\u2028B\u2029C"); + + Assert.True(ok); + Assert.Equal("A\\u2028B\\u2029C", builder.AsSpan()); + } + [Fact] public void AppendHtmlEscaped_Basic() { @@ -48,4 +60,43 @@ public void TryAppendCsvEscaped_Quotes_Commas_Newlines() Assert.True(ok); Assert.Equal("\" a,\"\"b\"\"\n\"", builder.AsSpan()); } -} \ No newline at end of file + + [Fact] + public void TryAppendJsonEscaped_InsufficientCapacity_DoesNotModifyBuilder() + { + Span buffer = stackalloc char[10]; + var builder = ZaSpanStringBuilder.Create(buffer); + builder.Append("hello"); + + var result = builder.TryAppendJsonEscaped("\"test\""); + + Assert.False(result); + Assert.Equal("hello", builder.AsSpan().ToString()); + } + + [Fact] + public void TryAppendHtmlEscaped_InsufficientCapacity_DoesNotModifyBuilder() + { + Span buffer = stackalloc char[10]; + var builder = ZaSpanStringBuilder.Create(buffer); + builder.Append("hello"); + + var result = builder.TryAppendHtmlEscaped(""); + + Assert.False(result); + Assert.Equal("hello", builder.AsSpan().ToString()); + } + + [Fact] + public void TryAppendCsvEscaped_InsufficientCapacity_DoesNotModifyBuilder() + { + Span buffer = stackalloc char[10]; + var builder = ZaSpanStringBuilder.Create(buffer); + builder.Append("hello"); + + var result = builder.TryAppendCsvEscaped("a,b\"c"); + + Assert.False(result); + Assert.Equal("hello", builder.AsSpan().ToString()); + } +} diff --git a/tests/ZaString.Tests/ZaSpanStringBuilderFormatTests.cs b/tests/ZaString.Tests/ZaSpanStringBuilderFormatTests.cs index 480a100..6c94f50 100644 --- a/tests/ZaString.Tests/ZaSpanStringBuilderFormatTests.cs +++ b/tests/ZaString.Tests/ZaSpanStringBuilderFormatTests.cs @@ -54,4 +54,31 @@ public void AppendFormat_WithISpanFormattable_UsesSpanFormattable() var expected = string.Format("Int: {0:N0}, Double: {1:F2}", 123, 456.789); Assert.Equal(expected, builder.AsSpan().ToString()); } + + [Fact] + public void AppendFormat_ReadOnlySpan_Works() + { + Span buffer = stackalloc char[256]; + var builder = ZaSpanStringBuilder.Create(buffer); + + var format = "User: {0}, Balance: {1:C}".AsSpan(); + builder.AppendFormat(format, "John", 1234.56); + + var expected = string.Format(CultureInfo.InvariantCulture, "User: {0}, Balance: {1:C}", "John", 1234.56); + Assert.Equal(expected, builder.AsSpan().ToString()); + } + + [Fact] + public void AppendFormat_ReadOnlySpan_WithCulture_Works() + { + Span buffer = stackalloc char[256]; + var builder = ZaSpanStringBuilder.Create(buffer); + + var culture = new CultureInfo("fr-FR"); + var format = "Balance: {0:C}".AsSpan(); + builder.AppendFormat(culture, format, 1234.56); + + var expected = string.Format(culture, "Balance: {0:C}", 1234.56); + 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 index 5e5e536..8464328 100644 --- a/tests/ZaString.Tests/ZaSpanStringBuilderInterpolationTests.cs +++ b/tests/ZaString.Tests/ZaSpanStringBuilderInterpolationTests.cs @@ -241,4 +241,96 @@ public void Append_InterpolatedString_WithSpecialCharacters() Assert.Equal("Special: \t\n\r", builder.AsSpan()); } -} \ No newline at end of file + + [Fact] + public void Append_InterpolatedString_WithPositiveAlignment() + { + Span buffer = stackalloc char[64]; + var builder = ZaSpanStringBuilder.Create(buffer); + + builder.Append($"[{42,5}]"); + + Assert.Equal("[ 42]", builder.AsSpan()); + } + + [Fact] + public void Append_InterpolatedString_WithNegativeAlignment() + { + Span buffer = stackalloc char[64]; + var builder = ZaSpanStringBuilder.Create(buffer); + + builder.Append($"[{42,-5}]"); + + Assert.Equal("[42 ]", builder.AsSpan()); + } + + [Fact] + public void Append_InterpolatedString_WithAlignmentAndFormat() + { + Span buffer = stackalloc char[64]; + var builder = ZaSpanStringBuilder.Create(buffer); + + builder.Append($"[{3.14159,8:F2}]"); + + Assert.Equal("[ 3.14]", builder.AsSpan()); + } + + [Fact] + public void Append_InterpolatedString_WithNegativeAlignmentAndFormat() + { + Span buffer = stackalloc char[64]; + var builder = ZaSpanStringBuilder.Create(buffer); + + builder.Append($"[{3.14159,-8:F2}]"); + + Assert.Equal("[3.14 ]", builder.AsSpan()); + } + + [Fact] + public void Append_InterpolatedString_WithNegativeAlignment_LeavesBuilderUnchangedWhenPaddingOverflows() + { + var buffer = "xxxxx".ToCharArray(); + + var exception = Assert.Throws(() => AppendNegativeAlignedValue(buffer)); + + Assert.Equal("value", exception.ParamName); + Assert.Equal("xxxxx", new string(buffer)); + } + + [Fact] + public void Append_InterpolatedString_WithNegativeAlignmentAndFormat_LeavesBuilderUnchangedWhenPaddingOverflows() + { + var buffer = "xxxxx".ToCharArray(); + + var exception = Assert.Throws(() => AppendNegativeAlignedFormattedValue(buffer)); + + Assert.Equal("value", exception.ParamName); + Assert.Equal("xxxxx", new string(buffer)); + } + + private static void AppendNegativeAlignedValue(char[] buffer) + { + var builder = ZaSpanStringBuilder.Create(buffer); + var handler = new ZaInterpolatedStringHandler(0, 1, ref builder); + handler.AppendFormatted(42, -6); + } + + private static void AppendNegativeAlignedFormattedValue(char[] buffer) + { + var builder = ZaSpanStringBuilder.Create(buffer); + var handler = new ZaInterpolatedStringHandler(0, 1, ref builder); + handler.AppendFormatted(3.14159, -6, "F2"); + } + + [Fact] + public void Append_InterpolatedString_WithAlignedString_Works() + { + Span buffer = stackalloc char[32]; + var builder = ZaSpanStringBuilder.Create(buffer); + + var name = "Ada"; + builder.Append($"|{name,6}|{name,-6}|"); + + Assert.Equal("| Ada|Ada |", builder.AsSpan()); + } +} diff --git a/tests/ZaString.Tests/ZaSpanStringBuilderMutationHelpersTests.cs b/tests/ZaString.Tests/ZaSpanStringBuilderMutationHelpersTests.cs index 2cdd879..8f12cd7 100644 --- a/tests/ZaString.Tests/ZaSpanStringBuilderMutationHelpersTests.cs +++ b/tests/ZaString.Tests/ZaSpanStringBuilderMutationHelpersTests.cs @@ -31,6 +31,19 @@ public void RemoveLast_RemovesCorrectly() Assert.Equal(4, builder.Length); } + [Fact] + public void RemoveLast_Extension_RemovesCorrectly() + { + Span buffer = stackalloc char[16]; + var builder = ZaSpanStringBuilder.Create(buffer); + builder.Append("abcdef"); + + ZaSpanStringBuilderExtensions.RemoveLast(ref builder, 2); + + Assert.Equal("abcd", builder.AsSpan()); + Assert.Equal(4, builder.Length); + } + [Fact] public void EnsureEndsWith_AppendsWhenMissing() { @@ -55,4 +68,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 26421ad..197978b 100644 --- a/tests/ZaString.Tests/ZaSpanStringBuilderTryAppendTests.cs +++ b/tests/ZaString.Tests/ZaSpanStringBuilderTryAppendTests.cs @@ -181,4 +181,18 @@ public void TryAppendLine_String_Succeeds_WhenCapacitySufficient() Assert.Equal(value + Environment.NewLine, builder.AsSpan()); Assert.Equal(required, builder.Length); } -} \ No newline at end of file + + [Theory] + [InlineData(5, 2, 7, true)] + [InlineData(5, 2, 6, false)] + [InlineData(int.MaxValue, 2, int.MaxValue, false)] + public void HasCapacityForLine_UsesOverflowSafeArithmetic(int valueLength, int newlineLength, int remainingLength, bool expected) + { + var hasCapacityForLine = typeof(ZaSpanStringBuilderExtensions) + .GetMethod("HasCapacityForLine", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static); + + Assert.NotNull(hasCapacityForLine); + var actual = (bool)hasCapacityForLine.Invoke(null, new object[] { valueLength, newlineLength, remainingLength })!; + Assert.Equal(expected, actual); + } +} diff --git a/tests/ZaString.Tests/ZaSpanStringBuilderUrlHelpersTests.cs b/tests/ZaString.Tests/ZaSpanStringBuilderUrlHelpersTests.cs index e636cb0..f1ddec6 100644 --- a/tests/ZaString.Tests/ZaSpanStringBuilderUrlHelpersTests.cs +++ b/tests/ZaString.Tests/ZaSpanStringBuilderUrlHelpersTests.cs @@ -24,7 +24,6 @@ public void AppendUrlEncoded_Reserved_And_NonAscii_PercentEncoded() 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()); } @@ -51,4 +50,100 @@ public void AppendQueryParam_Encodes_And_Uses_Correct_Delimiters() Assert.Equal("/search?q=a%20b&page=1", builder.AsSpan()); } -} \ No newline at end of file + + [Fact] + public void AppendQueryParam_WithRefBool_TracksFirstAndSubsequent() + { + Span buffer = stackalloc char[64]; + var builder = ZaSpanStringBuilder.Create(buffer); + + var isFirst = true; + builder.Append("/search") + .AppendQueryParam("q", "a b", ref isFirst) + .AppendQueryParam("page", "1", ref isFirst) + .AppendQueryParam("limit", "10", ref isFirst); + + Assert.Equal("/search?q=a%20b&page=1&limit=10", builder.AsSpan()); + Assert.False(isFirst); + } + + [Fact] + public void AppendQueryParam_WithRefBool_StartingFalse() + { + Span buffer = stackalloc char[64]; + var builder = ZaSpanStringBuilder.Create(buffer); + + var isFirst = false; + builder.Append("/search?q=existing") + .AppendQueryParam("page", "1", ref isFirst); + + Assert.Equal("/search?q=existing&page=1", builder.AsSpan()); + Assert.False(isFirst); + } + + [Fact] + public void AppendQueryParam_WithRefBool_Failure_IsAtomic() + { + Span buffer = stackalloc char[8]; + var builder = ZaSpanStringBuilder.Create(buffer); + builder.Append("/s"); + + var isFirst = true; + + try + { + builder.AppendQueryParam("longkey", "x", ref isFirst); + Assert.Fail("Expected ArgumentOutOfRangeException"); + } + catch (ArgumentOutOfRangeException) + { + } + + Assert.Equal("/s", builder.AsSpan()); + Assert.True(isFirst); + } + + [Fact] + public void AppendFormUrlEncoded_EncodesSpacesAsPlus() + { + Span buffer = stackalloc char[64]; + var builder = ZaSpanStringBuilder.Create(buffer); + + builder.AppendFormUrlEncoded("hello world"); + + Assert.Equal("hello+world", builder.AsSpan()); + } + + [Fact] + public void AppendFormUrlEncoded_EncodesSpecialChars() + { + Span buffer = stackalloc char[64]; + var builder = ZaSpanStringBuilder.Create(buffer); + + builder.AppendFormUrlEncoded("a=b&c"); + + Assert.Equal("a%3Db%26c", builder.AsSpan()); + } + + [Fact] + public void AppendFormUrlEncoded_MixedContent() + { + Span buffer = stackalloc char[128]; + var builder = ZaSpanStringBuilder.Create(buffer); + + builder.AppendFormUrlEncoded("hello world! a+b=c"); + + Assert.Equal("hello+world%21+a%2Bb%3Dc", builder.AsSpan()); + } + + [Fact] + public void AppendFormUrlEncoded_LoneHighSurrogate_UsesReplacementCharacter() + { + Span buffer = stackalloc char[32]; + var builder = ZaSpanStringBuilder.Create(buffer); + + builder.AppendFormUrlEncoded("\uD800"); + + Assert.Equal("%EF%BF%BD", builder.AsSpan()); + } +} diff --git a/tests/ZaString.Tests/ZaSpanStringBuilderUtf8Tests.cs b/tests/ZaString.Tests/ZaSpanStringBuilderUtf8Tests.cs index d054fc2..5d8d23b 100644 --- a/tests/ZaString.Tests/ZaSpanStringBuilderUtf8Tests.cs +++ b/tests/ZaString.Tests/ZaSpanStringBuilderUtf8Tests.cs @@ -105,4 +105,34 @@ public unsafe void TryToUtf8NullTerminated_WritesToPointer_WhenBufferIsLargeEnou Assert.Equal(0, buffer[5]); } } + + [Fact] + public unsafe void TryToUtf8NullTerminated_NegativeLength_ReturnsFalse() + { + Span charBuffer = stackalloc char[128]; + var builder = ZaSpanStringBuilder.Create(charBuffer); + builder.Append("Test"); + + var success = builder.TryToUtf8NullTerminated(null, -1, out var bytesWritten); + + Assert.False(success); + Assert.Equal(0, bytesWritten); + } + + [Fact] + public unsafe void TryToUtf8NullTerminated_NegativeLengthWithBuffer_ReturnsFalse() + { + Span charBuffer = stackalloc char[128]; + var builder = ZaSpanStringBuilder.Create(charBuffer); + builder.Append("Test"); + + Span buffer = stackalloc byte[10]; + fixed (byte* ptr = buffer) + { + var success = builder.TryToUtf8NullTerminated(ptr, -1, out var bytesWritten); + + Assert.False(success); + Assert.Equal(0, bytesWritten); + } + } } diff --git a/tests/ZaString.Tests/ZaUtf8SpanWriterTests.cs b/tests/ZaString.Tests/ZaUtf8SpanWriterTests.cs index 33db1d4..6c28bd1 100644 --- a/tests/ZaString.Tests/ZaUtf8SpanWriterTests.cs +++ b/tests/ZaString.Tests/ZaUtf8SpanWriterTests.cs @@ -294,6 +294,7 @@ public void Append_BufferTooSmall_ThrowsException() var writer = ZaUtf8SpanWriter.Create(buffer); var exceptionThrown = false; + ArgumentOutOfRangeException? caughtEx = null; try { writer.Append("Hello"); @@ -301,10 +302,11 @@ public void Append_BufferTooSmall_ThrowsException() catch (ArgumentOutOfRangeException ex) { exceptionThrown = true; - Assert.Contains("destination buffer is too small", ex.Message); + caughtEx = ex; } Assert.True(exceptionThrown, "Expected ArgumentOutOfRangeException to be thrown"); + Assert.Contains("destination buffer is too small", caughtEx!.Message); } [Fact] @@ -320,6 +322,7 @@ public void AppendHex_BufferTooSmall_ThrowsException() 0x03 }; var exceptionThrown = false; + ArgumentOutOfRangeException? caughtEx = null; try { writer.AppendHex(data); @@ -327,10 +330,11 @@ public void AppendHex_BufferTooSmall_ThrowsException() catch (ArgumentOutOfRangeException ex) { exceptionThrown = true; - Assert.Contains("destination buffer is too small", ex.Message); + caughtEx = ex; } Assert.True(exceptionThrown, "Expected ArgumentOutOfRangeException to be thrown"); + Assert.Contains("destination buffer is too small", caughtEx!.Message); } [Fact] @@ -349,6 +353,7 @@ public void AppendBase64_BufferTooSmall_ThrowsException() 0x06 }; var exceptionThrown = false; + ArgumentOutOfRangeException? caughtEx = null; try { writer.AppendBase64(data); @@ -356,10 +361,11 @@ public void AppendBase64_BufferTooSmall_ThrowsException() catch (ArgumentOutOfRangeException ex) { exceptionThrown = true; - Assert.Contains("destination buffer is too small", ex.Message); + caughtEx = ex; } Assert.True(exceptionThrown, "Expected ArgumentOutOfRangeException to be thrown"); + Assert.Contains("destination buffer is too small", caughtEx!.Message); } private static void AppendToWriter(ref ZaUtf8SpanWriter writer, string value)