Skip to content

Commit 566f065

Browse files
committed
feat: Introduce core ZaPooledStringBuilder and ZaSpanString classes with UTF-8 conversion capabilities and associated tests.
1 parent 9155b1f commit 566f065

6 files changed

Lines changed: 351 additions & 40 deletions

File tree

src/ZaString/Core/ZaPooledStringBuilder.cs

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
using System.Buffers;
22
using System.Globalization;
3+
using System.Text;
34

45
namespace ZaString.Core;
56

@@ -137,4 +138,47 @@ public ZaPooledStringBuilder AppendLine(string? value)
137138

138139
return AppendLine();
139140
}
141+
142+
public ZaUtf8Handle ToUtf8NullTerminated()
143+
{
144+
var span = AsSpan();
145+
var byteCount = Encoding.UTF8.GetByteCount(span);
146+
147+
var bytePool = ArrayPool<byte>.Shared;
148+
var byteBuffer = bytePool.Rent(byteCount + 1);
149+
150+
Encoding.UTF8.TryGetBytes(span, byteBuffer, out var bytesWritten);
151+
byteBuffer[bytesWritten] = 0; // Null terminate
152+
153+
return new ZaUtf8Handle(byteBuffer, bytesWritten + 1, bytePool);
154+
}
155+
156+
public bool TryToUtf8NullTerminated(Span<byte> destination, out int bytesWritten)
157+
{
158+
var span = AsSpan();
159+
var byteCount = Encoding.UTF8.GetByteCount(span);
160+
var required = byteCount + 1;
161+
162+
if (destination.Length < required)
163+
{
164+
bytesWritten = 0;
165+
return false;
166+
}
167+
168+
Encoding.UTF8.TryGetBytes(span, destination, out bytesWritten);
169+
destination[bytesWritten] = 0; // Null terminate
170+
bytesWritten++; // Include null terminator in count
171+
return true;
172+
}
173+
174+
public unsafe bool TryToUtf8NullTerminated(byte* buffer, int length, out int bytesWritten)
175+
{
176+
if (buffer == null)
177+
{
178+
bytesWritten = 0;
179+
return false;
180+
}
181+
182+
return TryToUtf8NullTerminated(new Span<byte>(buffer, length), out bytesWritten);
183+
}
140184
}

src/ZaString/Core/ZaSpanString.cs

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
using System.Diagnostics;
1+
using System.Buffers;
2+
using System.Diagnostics;
23
using System.Runtime.CompilerServices;
34
using System.Runtime.InteropServices;
45
using System.Text;
@@ -245,4 +246,45 @@ public readonly byte[] ToByteArray()
245246
ref var r = ref MemoryMarshal.GetReference(byteSpan);
246247
return (byte*)Unsafe.AsPointer(ref r);
247248
}
249+
250+
public readonly ZaUtf8Handle ToUtf8NullTerminated()
251+
{
252+
var byteCount = Encoding.UTF8.GetByteCount(WrittenSpan);
253+
254+
var bytePool = ArrayPool<byte>.Shared;
255+
var byteBuffer = bytePool.Rent(byteCount + 1);
256+
257+
Encoding.UTF8.TryGetBytes(WrittenSpan, byteBuffer, out var bytesWritten);
258+
byteBuffer[bytesWritten] = 0; // Null terminate
259+
260+
return new ZaUtf8Handle(byteBuffer, bytesWritten + 1, bytePool);
261+
}
262+
263+
public readonly bool TryToUtf8NullTerminated(Span<byte> destination, out int bytesWritten)
264+
{
265+
var byteCount = Encoding.UTF8.GetByteCount(WrittenSpan);
266+
var required = byteCount + 1;
267+
268+
if (destination.Length < required)
269+
{
270+
bytesWritten = 0;
271+
return false;
272+
}
273+
274+
Encoding.UTF8.TryGetBytes(WrittenSpan, destination, out bytesWritten);
275+
destination[bytesWritten] = 0; // Null terminate
276+
bytesWritten++; // Include null terminator in count
277+
return true;
278+
}
279+
280+
public unsafe readonly bool TryToUtf8NullTerminated(byte* buffer, int length, out int bytesWritten)
281+
{
282+
if (buffer == null)
283+
{
284+
bytesWritten = 0;
285+
return false;
286+
}
287+
288+
return TryToUtf8NullTerminated(new Span<byte>(buffer, length), out bytesWritten);
289+
}
248290
}

src/ZaString/Core/ZaUtf8Handle.cs

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
using System;
2+
using System.Buffers;
3+
using System.Runtime.CompilerServices;
4+
using System.Runtime.InteropServices;
5+
6+
namespace ZaString.Core;
7+
8+
/// <summary>
9+
/// A disposable handle for a pooled UTF-8 byte buffer.
10+
/// This struct MUST be disposed to return the buffer to the pool.
11+
/// </summary>
12+
public struct ZaUtf8Handle : IDisposable
13+
{
14+
private byte[]? _buffer;
15+
private readonly ArrayPool<byte> _pool;
16+
private readonly int _length;
17+
18+
internal ZaUtf8Handle(byte[] buffer, int length, ArrayPool<byte> pool)
19+
{
20+
_buffer = buffer;
21+
_length = length;
22+
_pool = pool;
23+
}
24+
25+
/// <summary>
26+
/// Gets the span representing the UTF-8 data, including the null terminator.
27+
/// </summary>
28+
public ReadOnlySpan<byte> Span
29+
{
30+
get
31+
{
32+
return _buffer == null ? ReadOnlySpan<byte>.Empty : new ReadOnlySpan<byte>(_buffer, 0, _length);
33+
}
34+
}
35+
36+
/// <summary>
37+
/// Gets a pointer to the underlying buffer.
38+
/// WARNING: This pointer is valid only as long as the handle is not disposed and the underlying array is pinned (if needed).
39+
/// Since this uses pooled arrays, they are pinned by default in modern .NET during usage if not moved, but exercise caution.
40+
/// Actually, standard arrays are NOT pinned. Users should pin if they need a stable pointer for async/external calls.
41+
/// For immediate synchronous usage (like ImGui), it is usually fine.
42+
/// </summary>
43+
public readonly unsafe byte* Pointer
44+
{
45+
get
46+
{
47+
if (_buffer == null) return null;
48+
return (byte*)Unsafe.AsPointer(ref MemoryMarshal.GetArrayDataReference(_buffer));
49+
}
50+
}
51+
52+
/// <summary>
53+
/// Returns the underlying buffer to the pool.
54+
/// </summary>
55+
public void Dispose()
56+
{
57+
if (_buffer == null)
58+
return;
59+
_pool.Return(_buffer);
60+
_buffer = null;
61+
}
62+
}

src/ZaString/ZaString.csproj

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,8 @@
2121
<Deterministic>true</Deterministic>
2222
<ContinuousIntegrationBuild>true</ContinuousIntegrationBuild>
2323
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
24-
<Version>0.2.5</Version>
25-
<PackageVersion>0.2.5</PackageVersion>
24+
<Version>0.2.6</Version>
25+
<PackageVersion>0.2.6</PackageVersion>
2626
<MinVerSkip>true</MinVerSkip>
2727

2828
<!-- MinVer configuration -->
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
using System.Text;
2+
using ZaString.Core;
3+
4+
namespace ZaString.Tests;
5+
6+
public class ZaPooledStringBuilderUtf8Tests
7+
{
8+
[Fact]
9+
public void ToUtf8NullTerminated_ReturnsCorrectBytes()
10+
{
11+
using var builder = ZaPooledStringBuilder.Rent();
12+
builder.Append("Hello World");
13+
14+
using var handle = builder.ToUtf8NullTerminated();
15+
var span = handle.Span;
16+
17+
Assert.Equal(12, span.Length); // 11 chars + 1 null terminator
18+
Assert.Equal(0, span[11]); // Null terminator
19+
20+
var str = Encoding.UTF8.GetString(span[..11]);
21+
Assert.Equal("Hello World", str);
22+
}
23+
24+
[Fact]
25+
public void ToUtf8NullTerminated_EmptyString_ReturnsNullTerminatorOnly()
26+
{
27+
using var builder = ZaPooledStringBuilder.Rent();
28+
29+
using var handle = builder.ToUtf8NullTerminated();
30+
var span = handle.Span;
31+
32+
Assert.Equal(1, span.Length);
33+
Assert.Equal(0, span[0]);
34+
}
35+
36+
[Fact]
37+
public void ToUtf8NullTerminated_SpecialCharacters_ReturnsCorrectBytes()
38+
{
39+
using var builder = ZaPooledStringBuilder.Rent();
40+
builder.Append("Héllo Wörld €");
41+
42+
using var handle = builder.ToUtf8NullTerminated();
43+
var span = handle.Span;
44+
45+
// "Héllo Wörld €"
46+
// é is 2 bytes (C3 A9)
47+
// ö is 2 bytes (C3 B6)
48+
// € is 3 bytes (E2 82 AC)
49+
// H, l, l, o, , W, r, l, d, are 1 byte each (10 chars)
50+
// Total bytes = 10 + 2 + 2 + 3 = 17 bytes
51+
// + 1 null terminator = 18 bytes
52+
53+
Assert.Equal(18, span.Length);
54+
Assert.Equal(0, span[17]);
55+
56+
var str = Encoding.UTF8.GetString(span[..17]);
57+
Assert.Equal("Héllo Wörld €", str);
58+
}
59+
60+
[Fact]
61+
public void TryToUtf8NullTerminated_WritesToBuffer_WhenBufferIsLargeEnough()
62+
{
63+
using var builder = ZaPooledStringBuilder.Rent();
64+
builder.Append("Hello");
65+
66+
Span<byte> buffer = stackalloc byte[10];
67+
var success = builder.TryToUtf8NullTerminated(buffer, out var bytesWritten);
68+
69+
Assert.True(success);
70+
Assert.Equal(6, bytesWritten); // 5 chars + 1 null terminator
71+
Assert.Equal((byte)'H', buffer[0]);
72+
Assert.Equal((byte)'e', buffer[1]);
73+
Assert.Equal((byte)'l', buffer[2]);
74+
Assert.Equal((byte)'l', buffer[3]);
75+
Assert.Equal((byte)'o', buffer[4]);
76+
Assert.Equal(0, buffer[5]);
77+
}
78+
79+
[Fact]
80+
public void TryToUtf8NullTerminated_ReturnsFalse_WhenBufferIsTooSmall()
81+
{
82+
using var builder = ZaPooledStringBuilder.Rent();
83+
builder.Append("Hello");
84+
85+
Span<byte> buffer = stackalloc byte[5]; // Too small (needs 6)
86+
var success = builder.TryToUtf8NullTerminated(buffer, out var bytesWritten);
87+
88+
Assert.False(success);
89+
Assert.Equal(0, bytesWritten);
90+
}
91+
92+
[Fact]
93+
public unsafe void TryToUtf8NullTerminated_WritesToPointer_WhenBufferIsLargeEnough()
94+
{
95+
using var builder = ZaPooledStringBuilder.Rent();
96+
builder.Append("Hello");
97+
98+
Span<byte> buffer = stackalloc byte[10];
99+
fixed (byte* ptr = buffer)
100+
{
101+
var success = builder.TryToUtf8NullTerminated(ptr, 10, out var bytesWritten);
102+
103+
Assert.True(success);
104+
Assert.Equal(6, bytesWritten);
105+
Assert.Equal((byte)'H', buffer[0]);
106+
Assert.Equal(0, buffer[5]);
107+
}
108+
}
109+
110+
[Fact]
111+
public unsafe void ZaUtf8Handle_Pointer_ReturnsValidPointer()
112+
{
113+
using var builder = ZaPooledStringBuilder.Rent();
114+
builder.Append("Test");
115+
116+
using var handle = builder.ToUtf8NullTerminated();
117+
var ptr = handle.Pointer;
118+
119+
Assert.True(ptr != null);
120+
Assert.Equal((byte)'T', *ptr);
121+
Assert.Equal((byte)'e', *(ptr + 1));
122+
Assert.Equal((byte)'s', *(ptr + 2));
123+
Assert.Equal((byte)'t', *(ptr + 3));
124+
Assert.Equal(0, *(ptr + 4));
125+
}
126+
}

0 commit comments

Comments
 (0)